diff --git a/datalab/config.py b/datalab/config.py
index c754cd6b..d4f7994c 100644
--- a/datalab/config.py
+++ b/datalab/config.py
@@ -297,6 +297,11 @@ class ProcSection(conf.Section, metaclass=conf.SectionMeta):
# - False: do not ignore warnings
ignore_warnings = conf.Option()
+ # Automatically start recording history at DataLab launch:
+ # - True: history recording is enabled at startup (default)
+ # - False: user must enable it manually via the History panel toolbar
+ history_auto_record = conf.Option()
+
# X-array compatibility behavior for multi-signal computations:
# - "ask": ask user for confirmation when x-arrays are incompatible (default)
# - "interpolate": automatically interpolate when x-arrays are incompatible
@@ -643,6 +648,7 @@ def initialize():
Conf.proc.keep_results.get(False)
Conf.proc.show_result_dialog.get(True)
Conf.proc.ignore_warnings.get(False)
+ Conf.proc.history_auto_record.get(True)
Conf.proc.xarray_compat_behavior.get("ask")
Conf.proc.small_mono_font.get((configtools.MONOSPACE, 8, False))
# View section
diff --git a/datalab/data/icons/edit_mode.svg b/datalab/data/icons/edit_mode.svg
new file mode 100644
index 00000000..6afff812
--- /dev/null
+++ b/datalab/data/icons/edit_mode.svg
@@ -0,0 +1,44 @@
+
+
+
+
diff --git a/datalab/data/icons/record.svg b/datalab/data/icons/record.svg
new file mode 100644
index 00000000..5017f50e
--- /dev/null
+++ b/datalab/data/icons/record.svg
@@ -0,0 +1,43 @@
+
+
+
+
diff --git a/datalab/data/icons/replay.svg b/datalab/data/icons/replay.svg
new file mode 100644
index 00000000..7dd374a1
--- /dev/null
+++ b/datalab/data/icons/replay.svg
@@ -0,0 +1,51 @@
+
+
diff --git a/datalab/data/icons/restore_and_replay.svg b/datalab/data/icons/restore_and_replay.svg
new file mode 100644
index 00000000..f27f163c
--- /dev/null
+++ b/datalab/data/icons/restore_and_replay.svg
@@ -0,0 +1,66 @@
+
+
diff --git a/datalab/data/icons/restore_selection.svg b/datalab/data/icons/restore_selection.svg
new file mode 100644
index 00000000..156b7126
--- /dev/null
+++ b/datalab/data/icons/restore_selection.svg
@@ -0,0 +1,52 @@
+
+
diff --git a/datalab/gui/historysession_ops.py b/datalab/gui/historysession_ops.py
new file mode 100644
index 00000000..1d31be23
--- /dev/null
+++ b/datalab/gui/historysession_ops.py
@@ -0,0 +1,312 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Helpers for History panel session recording and indexing."""
+
+from __future__ import annotations
+
+import logging
+from contextlib import contextmanager
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any, Callable, Generator
+
+from datalab.history import HistoryAction, HistorySession, WorkspaceState
+from datalab.history.core import _resolve_self_target
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.history import HistoryPanel
+
+_logger = logging.getLogger(__name__)
+
+
+def create_new_session(panel: HistoryPanel) -> None:
+ """Create a new history list"""
+ panel.session_increment += 1
+ session = HistorySession(number=panel.session_increment)
+ panel.history_sessions.append(session)
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+
+
+def start_new_session_after_workspace_reset(panel: HistoryPanel) -> None:
+ """Start a new history session after a workspace reset, when useful."""
+ if panel.history_sessions and panel.history_sessions[-1].actions:
+ panel.create_new_session()
+
+
+def add_compute_entry(
+ panel: HistoryPanel,
+ action_title: str,
+ panel_str: str,
+ func_name: str,
+ pattern: str,
+ save_state: bool = True,
+ output_uuids: list[str] | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ **kwargs: Any,
+) -> HistoryAction | None:
+ """Record a *compute* action in the current history session.
+
+ Args:
+ action_title: Title shown in the history tree.
+ panel_str: ``"signal"`` or ``"image"``.
+ func_name: Sigima feature name (resolvable via
+ :meth:`BaseProcessor.get_feature`).
+ pattern: One of ``"1_to_1"``, ``"1_to_0"``, ``"n_to_1"``, ``"2_to_1"``,
+ ``"1_to_n"``, ``"multiple_1_to_1"`` (the latter is recorded for
+ traceability but not replayable).
+ save_state: If True, capture the workspace state for replay.
+ output_uuids: Optional list of UUIDs of the data objects produced by
+ this action. When known at call time, prefer passing it here so the
+ bijective mapping is initialised in one step. Most callers do not
+ know the outputs yet and instead wrap the compute call with
+ :meth:`capture_outputs` (or call :meth:`register_action_outputs`
+ explicitly afterwards) using the returned action.
+ plugin_origin: Optional plugin origin descriptor (see
+ :func:`datalab.gui.processor.base._detect_plugin_origin`). ``None``
+ for built-in Sigima/DataLab features.
+ **kwargs: Extra primitive kwargs (``param``, ``obj2_uuids``,
+ ``obj2_name``, ``pairwise``, ``params`` (list of DataSet),
+ ``func_names`` (list of str), ...). ``DataSet`` instances are
+ serialised as JSON.
+
+ Returns:
+ The created :class:`HistoryAction`, or ``None`` if recording is
+ disabled (record mode off or replay in progress).
+ """
+ if not panel.record_mode_enabled or panel.is_replaying():
+ return None
+ state = WorkspaceState()
+ if save_state:
+ state.save(panel.mainwindow)
+ # Deep-copy kwargs so each action owns independent parameter
+ # instances. Without this, consecutive applications of the same
+ # function (e.g. two gaussian_filter calls with different sigma)
+ # would share a single DataSet object and editing one action's
+ # parameters would silently mutate the other.
+ action = HistoryAction(
+ title=action_title,
+ kind=HistoryAction.KIND_COMPUTE,
+ panel_str=panel_str,
+ func_name=func_name,
+ pattern=pattern,
+ kwargs=deepcopy(kwargs),
+ state=state,
+ plugin_origin=plugin_origin,
+ )
+ panel.add_object(action)
+ if output_uuids is not None:
+ panel.register_action_outputs(action, output_uuids)
+ return action
+
+
+def add_compute_entry_from_pp(
+ panel: HistoryPanel,
+ action_title: str,
+ pp: Any, # ProcessingParameters (avoid circular import)
+ panel_str: str,
+ save_state: bool = True,
+ output_uuids: list[str] | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ **extras: Any,
+) -> HistoryAction | None:
+ """Record a *compute* action derived from a ``ProcessingParameters``.
+
+ Bridges the dash-form pattern used in object metadata
+ (``"1-to-1"`` …) with the underscore form expected by
+ :class:`HistoryAction` (``"1_to_1"`` …) so that both sides share
+ a single identity (``func_name`` / ``pattern`` / ``param``).
+
+ Args:
+ action_title: Title shown in the history tree.
+ pp: :class:`~datalab.gui.processor.base.ProcessingParameters`
+ instance describing the operation.
+ panel_str: ``"signal"`` or ``"image"``.
+ save_state: If True, capture the workspace state for replay.
+ output_uuids: Optional list of UUIDs of the data objects produced
+ by this action (see :meth:`add_compute_entry`).
+ plugin_origin: Optional plugin origin descriptor (see
+ :meth:`add_compute_entry`).
+ **extras: Additional history-only kwargs (``obj2_uuids``,
+ ``obj2_name``, ``pairwise``, ``params``, ``func_names``…).
+
+ Returns:
+ The created :class:`HistoryAction`, or ``None`` if recording is
+ disabled.
+ """
+ hist_pattern = pp.pattern.replace("-", "_")
+ kwargs: dict[str, Any] = {}
+ if pp.param is not None and "param" not in extras and "params" not in extras:
+ kwargs["param"] = pp.param
+ kwargs.update(extras)
+ return panel.add_compute_entry(
+ action_title,
+ panel_str=panel_str,
+ func_name=pp.func_name,
+ pattern=hist_pattern,
+ save_state=save_state,
+ output_uuids=output_uuids,
+ plugin_origin=plugin_origin,
+ **kwargs,
+ )
+
+
+def register_action_outputs(
+ panel: HistoryPanel, action: HistoryAction, output_uuids: list[str]
+) -> None:
+ """Register the data objects produced by ``action``.
+
+ Maintains the bijective ``action → outputs`` and ``output → action``
+ mappings. May be called multiple times for a given action (later calls
+ replace earlier ones, e.g. after a cascade recompute).
+
+ Args:
+ action: The history action that produced the outputs.
+ output_uuids: UUIDs of the produced data objects (empty for
+ ``1_to_0`` analysis patterns and for UI actions that did not
+ create new objects).
+ """
+ # Drop previous outputs for this action from the reverse index.
+ previous = panel.action_output_uuids.get(action.uuid, [])
+ for prev_uuid in previous:
+ if panel.output_to_action.get(prev_uuid) == action.uuid:
+ panel.output_to_action.pop(prev_uuid, None)
+ new_outputs = list(output_uuids)
+ # Ownership transfer: if an output_uuid already belongs to a
+ # *different* action, remove it from that action's output list so the
+ # forward mapping stays consistent. The HistoryAction object's
+ # ``output_uuids`` attribute is NOT updated here because traversing all
+ # sessions to locate the object would be expensive; the panel-level
+ # dicts are the source of truth.
+ for out_uuid in new_outputs:
+ old_action_uuid = panel.output_to_action.get(out_uuid)
+ if old_action_uuid is not None and old_action_uuid != action.uuid:
+ old_list = panel.action_output_uuids.get(old_action_uuid)
+ if old_list is not None:
+ try:
+ old_list.remove(out_uuid)
+ except ValueError:
+ pass
+ if not old_list:
+ del panel.action_output_uuids[old_action_uuid]
+ _logger.debug(
+ "Output %s transferred from action %s to %s",
+ out_uuid,
+ old_action_uuid,
+ action.uuid,
+ )
+ action.output_uuids = list(new_outputs)
+ panel.action_output_uuids[action.uuid] = new_outputs
+ for out_uuid in new_outputs:
+ panel.output_to_action[out_uuid] = action.uuid
+
+
+@contextmanager
+def capture_outputs(
+ panel: HistoryPanel, action: HistoryAction | None
+) -> Generator[None, None, None]:
+ """Context manager: snapshot panel object IDs and record diffs as outputs.
+
+ Use around any compute call when the produced UUIDs are not known
+ upfront. On exit, every newly-added object (signal or image) is
+ registered as an output of ``action`` via
+ :meth:`register_action_outputs`. No-op when ``action`` is ``None``
+ (recording disabled).
+
+ Args:
+ action: The history action being processed, or ``None``.
+ """
+ if action is None:
+ yield
+ return
+ panels = (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel)
+ before = {p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in panels}
+ try:
+ yield
+ finally:
+ new_uuids: list[str] = []
+ for p in panels:
+ before_p = before[p.PANEL_STR_ID]
+ for uid in p.objmodel.get_object_ids():
+ if uid not in before_p:
+ new_uuids.append(uid)
+ panel.register_action_outputs(action, new_uuids)
+
+
+def add_ui_entry(
+ panel: HistoryPanel,
+ action_title: str,
+ target: str,
+ method_name: str,
+ save_state: bool = True,
+ **kwargs: Any,
+) -> None:
+ """Record a *UI* action in the current history session.
+
+ Args:
+ action_title: Title shown in the history tree.
+ target: One of ``"mainwindow"``, ``"signalpanel"``, ``"imagepanel"``,
+ ``"historypanel"`` -- attribute path on the main window.
+ method_name: Method name to call on ``target`` at replay time.
+ save_state: If True, capture the workspace state for replay.
+ **kwargs: Method keyword arguments. ``DataSet`` instances are
+ serialised as JSON; other values must be HDF5-friendly primitives.
+ """
+ if not panel.record_mode_enabled or panel.is_replaying():
+ return
+ state = WorkspaceState()
+ if save_state:
+ state.save(panel.mainwindow)
+ # Deep-copy kwargs to ensure independent parameter ownership
+ # (same rationale as in add_compute_entry).
+ action = HistoryAction(
+ title=action_title,
+ kind=HistoryAction.KIND_UI,
+ target=target,
+ method_name=method_name,
+ kwargs=deepcopy(kwargs),
+ state=state,
+ )
+ panel.add_object(action)
+
+
+def add_entry(
+ panel: HistoryPanel,
+ action_title: str,
+ save_state: bool,
+ func: Callable,
+ **kwargs,
+) -> None:
+ """Legacy entry-point kept as a compatibility shim.
+
+ Most call sites have been migrated to :meth:`add_compute_entry` or
+ :meth:`add_ui_entry`. The remaining paths -- and the
+ :func:`add_to_history` decorator -- still call ``add_entry`` with a
+ bound method; we infer the ``(target, method_name)`` from the bound
+ ``func.__self__`` and route to :meth:`add_ui_entry`.
+ """
+ if not panel.record_mode_enabled or panel.is_replaying():
+ return
+ target = None
+ if hasattr(func, "__self__"):
+ target = _resolve_self_target(func.__self__)
+ if target is None:
+ # Cannot route safely -- skip rather than pickle a Callable.
+ return
+ panel.add_ui_entry(
+ action_title,
+ target=target,
+ method_name=func.__name__,
+ save_state=save_state,
+ **kwargs,
+ )
+
+
+def add_object(panel: HistoryPanel, obj: HistoryAction) -> None:
+ """Add object to panel"""
+ if not panel.history_sessions:
+ panel.create_new_session()
+ panel.history_sessions[-1].add_action(obj)
+ panel.tree.add_action_to_tree(obj)
+ panel.tree.rearrange_tree()
+ panel.refresh_compatibility_items()
+ panel.update_actions_state()
diff --git a/datalab/gui/historytools_ops.py b/datalab/gui/historytools_ops.py
new file mode 100644
index 00000000..e4d29c8b
--- /dev/null
+++ b/datalab/gui/historytools_ops.py
@@ -0,0 +1,456 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Helpers for History panel session tools."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import TYPE_CHECKING
+from uuid import uuid4
+
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.processor.base import (
+ PROCESSING_PARAMETERS_OPTION,
+ ProcessingParameters,
+)
+from datalab.history import HistoryAction, HistorySession
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.history import HistoryPanel
+
+
+def duplicate_selected_entries(panel: HistoryPanel) -> None:
+ """Duplicate selected sessions (with their data) into new independent sessions.
+
+ For each selected session (or the parent session of a selected action),
+ all referenced data objects are deep-copied into a new group and the
+ session is duplicated with all UUID references rewritten to the clones.
+ The result is an independent, editable and replayable session.
+ """
+ selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions)
+ if not selected:
+ return
+ # Normalise: resolve individual actions to their parent session, deduplicate.
+ sessions_to_dup: list[HistorySession] = []
+ seen: set[int] = set()
+ for item in selected:
+ if isinstance(item, HistorySession):
+ session = item
+ else:
+ session = panel.find_parent_session(item)
+ if session is None:
+ continue
+ if id(session) not in seen:
+ seen.add(id(session))
+ sessions_to_dup.append(session)
+
+ copy_suffix = _("Copy")
+ new_sessions: list[HistorySession] = []
+ panel_map = {
+ "signal": panel.mainwindow.signalpanel,
+ "image": panel.mainwindow.imagepanel,
+ }
+
+ for session in sessions_to_dup:
+ # 1. Collect all UUIDs referenced by this session
+ uuids_by_panel: dict[str, set[str]] = {}
+ for action in session.actions:
+ for pstr, uuids in action.state.selection.items():
+ uuids_by_panel.setdefault(pstr, set()).update(uuids)
+ for pstr, metadata in action.state.object_metadata.items():
+ uuids_by_panel.setdefault(pstr, set()).update(metadata.keys())
+ obj2 = action.kwargs.get("obj2_uuids")
+ if obj2:
+ pstr = action.panel_str or ""
+ if isinstance(obj2, str):
+ obj2 = [obj2]
+ uuids_by_panel.setdefault(pstr, set()).update(obj2)
+ # Output UUIDs produced by this action (e.g. result of a
+ # compute step). Without this, the last action's outputs
+ # would be missing because no subsequent state captures them.
+ if action.output_uuids:
+ pstr = action.panel_str or ""
+ uuids_by_panel.setdefault(pstr, set()).update(action.output_uuids)
+
+ # 2. Clone objects and build uuid_remap
+ uuid_remap: dict[str, dict[str, str]] = {}
+ clones_by_pstr: dict[str, list] = {}
+ group_title = f"{copy_suffix} - {session.title}"
+ for pstr, uuids in uuids_by_panel.items():
+ data_panel = panel_map.get(pstr)
+ if data_panel is None:
+ continue
+ uuid_remap[pstr] = {}
+ existing_ids = set(data_panel.objmodel.get_object_ids())
+ clones = []
+ # Iterate in panel order (not set order) to preserve
+ # the topological object ordering in the duplicated group.
+ ordered_ids = [
+ u for u in data_panel.objmodel.get_object_ids() if u in uuids
+ ]
+ for old_uuid in ordered_ids:
+ if old_uuid not in existing_ids:
+ continue
+ obj = data_panel.objmodel[old_uuid]
+ clone = deepcopy(obj)
+ new_uuid = str(uuid4())
+ # SignalObj/ImageObj store UUID via metadata option
+ try:
+ clone.set_metadata_option("uuid", new_uuid)
+ except AttributeError:
+ clone.uuid = new_uuid
+ uuid_remap[pstr][old_uuid] = new_uuid
+ clones.append(clone)
+ clones_by_pstr[pstr] = clones
+ if clones:
+ group_id = get_uuid(data_panel.add_group(group_title))
+ for clone in clones:
+ data_panel.add_object(clone, group_id=group_id)
+
+ # Second pass: remap source UUIDs in cloned objects'
+ # processing_parameters so reprocessing in the Processing tab
+ # uses the cloned source, not the original.
+ for pstr_inner, clones_inner in clones_by_pstr.items():
+ pmap = uuid_remap.get(pstr_inner, {})
+ if not pmap:
+ continue
+ for clone in clones_inner:
+ try:
+ pp_dict = clone.get_metadata_option(PROCESSING_PARAMETERS_OPTION)
+ except (AttributeError, ValueError):
+ continue
+ if not pp_dict:
+ continue
+ try:
+ pp = ProcessingParameters.from_dict(pp_dict)
+ except (TypeError, ValueError, AttributeError):
+ continue
+ changed = False
+ if pp.source_uuid is not None and pp.source_uuid in pmap:
+ pp.source_uuid = pmap[pp.source_uuid]
+ changed = True
+ if pp.source_uuids is not None:
+ new_src = [pmap.get(u, u) for u in pp.source_uuids]
+ if new_src != pp.source_uuids:
+ pp.source_uuids = new_src
+ changed = True
+ if changed:
+ try:
+ clone.set_metadata_option(
+ PROCESSING_PARAMETERS_OPTION, pp.to_dict()
+ )
+ except (AttributeError, ValueError):
+ pass
+
+ # 3. Build the new session with remapped UUIDs
+ panel.session_increment += 1
+ title = f"{session.title} {copy_suffix}"
+ new_session = session.copy_with_uuid_remap(title=title, uuid_remap=uuid_remap)
+ new_session.number = panel.session_increment
+ new_sessions.append(new_session)
+
+ # Register output mappings for cloned actions so that
+ # resolve_target_outputs / get_downstream_actions work on
+ # the duplicated session (same logic as deserialize_from_hdf5).
+ for action in new_session.actions:
+ if action.output_uuids:
+ panel.action_output_uuids[action.uuid] = list(action.output_uuids)
+ for out_uuid in action.output_uuids:
+ panel.output_to_action[out_uuid] = action.uuid
+
+ # Insert each duplicated session immediately after its original.
+ offset = 0
+ for original_session, new_session in zip(sessions_to_dup, new_sessions):
+ idx = panel.history_sessions.index(original_session)
+ panel.history_sessions.insert(idx + 1 + offset, new_session)
+ offset += 1
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.select_sessions(new_sessions)
+ panel.refresh_compatibility_items()
+ panel.update_actions_state()
+
+
+def generate_macro(panel: HistoryPanel) -> None:
+ """Generate a standalone Python script from selected history entries.
+
+ The generated script uses sigima functions directly with proper variable
+ chaining. Object references (UUIDs) are resolved to variable names so
+ that 2-to-1 operations reference the correct intermediate result.
+ The script is copied to the clipboard and the user is notified.
+ """
+ selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions)
+ actions: list[HistoryAction] = []
+ if not selected:
+ for session in panel.history_sessions:
+ actions.extend(session.actions)
+ else:
+ for item in selected:
+ if isinstance(item, HistorySession):
+ actions.extend(item.actions)
+ else:
+ actions.append(item)
+ if not actions:
+ return
+
+ # Filter to compute-only actions for the pipeline
+ compute_actions = [a for a in actions if a.kind == HistoryAction.KIND_COMPUTE]
+ if not compute_actions:
+ if not execenv.unattended:
+ QW.QMessageBox.information(
+ panel.mainwindow,
+ _("Generate macro"),
+ _("No compute actions to export."),
+ )
+ return
+
+ # Determine input type from first action
+ first_panel = compute_actions[0].panel_str
+ if first_panel == "signal":
+ obj_type = "SignalObj"
+ obj_import = "from sigima.objects import SignalObj"
+ else:
+ obj_type = "ImageObj"
+ obj_import = "from sigima.objects import ImageObj"
+
+ imports: set[str] = set()
+ imports.add(obj_import)
+ body_lines: list[str] = []
+
+ # UUID → variable mapping for resolving object references.
+ # Populated with input UUIDs ("src", "src_2", ...) and enriched
+ # with each step's output UUID after code generation.
+ uuid_to_var: dict[str, str] = {}
+
+ # Extra input parameters discovered during generation (second
+ # operands that are not produced by any previous step).
+ extra_inputs: list[str] = []
+
+ # Seed the mapping with the first action's input selection.
+ first_sel = compute_actions[0].state.selection.get(compute_actions[0].panel_str, [])
+ for i, uuid in enumerate(first_sel):
+ var = "src" if i == 0 else f"src_{i + 1}"
+ uuid_to_var[uuid] = var
+
+ step = 0
+ current_var = "src"
+
+ for action in compute_actions:
+ step += 1
+
+ # Resolve input variable from the action's selection UUIDs.
+ sel_uuids = action.state.selection.get(action.panel_str or "", [])
+ if sel_uuids and sel_uuids[0] in uuid_to_var:
+ input_var = uuid_to_var[sel_uuids[0]]
+ else:
+ input_var = current_var
+
+ # Resolve second operand for 2-to-1 patterns.
+ obj2_var: str | None = None
+ if action.pattern == "2_to_1":
+ obj2_uuids = action.kwargs.get("obj2_uuids", [])
+ if isinstance(obj2_uuids, str):
+ obj2_uuids = [obj2_uuids]
+ if obj2_uuids:
+ obj2_uuid = obj2_uuids[0]
+ if obj2_uuid in uuid_to_var:
+ obj2_var = uuid_to_var[obj2_uuid]
+ else:
+ # External input — add as function parameter.
+ obj2_var = f"obj2_{step}"
+ uuid_to_var[obj2_uuid] = obj2_var
+ extra_inputs.append(obj2_var)
+
+ code_lines, output_var = action.to_macro_code(
+ step, input_var, imports, obj2_var=obj2_var
+ )
+ body_lines.extend(code_lines)
+ body_lines.append("")
+
+ if output_var is not None:
+ current_var = output_var
+ # Map the output UUID so subsequent steps can reference it.
+ output_uuid = panel.action_output_uuid(action)
+ if output_uuid:
+ uuid_to_var[output_uuid] = output_var
+ # Also register any new UUIDs from the action's selection
+ # that we haven't seen yet (secondary selections).
+ for uuid in sel_uuids[1:]:
+ if uuid not in uuid_to_var:
+ uuid_to_var[uuid] = input_var
+
+ # Build the function signature with extra inputs.
+ params_str = f"src: {obj_type}"
+ for extra in extra_inputs:
+ params_str += f", {extra}: {obj_type}"
+
+ # Assemble the full script
+ sorted_imports = sorted(imports)
+ script_lines: list[str] = [
+ '"""',
+ "DataLab — standalone processing pipeline",
+ f"Generated from history ({len(compute_actions)} steps)",
+ '"""',
+ "",
+ ]
+ script_lines.extend(sorted_imports)
+ script_lines.append("")
+ script_lines.append("")
+ script_lines.append(f"def process({params_str}) -> {obj_type}:")
+ script_lines.append(' """Apply the recorded processing pipeline."""')
+ for line in body_lines:
+ script_lines.append(f" {line}" if line else "")
+ script_lines.append(f" return {current_var}")
+ script_lines.append("")
+ script_lines.append("")
+ script_lines.append('if __name__ == "__main__":')
+ script_lines.append(" # Standalone execution: run from DataLab's Macro panel.")
+ script_lines.append(" # Operates on the current object of the target panel.")
+ script_lines.append(" from datalab.control.proxy import RemoteProxy")
+ script_lines.append("")
+ script_lines.append(" proxy = RemoteProxy()")
+ panel_str = compute_actions[0].panel_str or (
+ "signal" if obj_type == "SignalObj" else "image"
+ )
+ script_lines.append(f' proxy.set_current_panel("{panel_str}")')
+ script_lines.append(" src = proxy.get_object()")
+ script_lines.append(" if src is None:")
+ script_lines.append(
+ f' raise RuntimeError("No current object in panel: {panel_str}")'
+ )
+ if extra_inputs:
+ n_extra = len(extra_inputs)
+ script_lines.append(
+ " _uuids = [u for u in proxy.get_sel_object_uuids() if u != src.uuid]"
+ )
+ script_lines.append(f" if len(_uuids) < {n_extra}:")
+ script_lines.append(" raise RuntimeError(")
+ script_lines.append(
+ f' "Pipeline needs {n_extra} extra selected'
+ ' object(s) besides the current one"'
+ )
+ script_lines.append(" )")
+ for idx, extra in enumerate(extra_inputs):
+ script_lines.append(
+ f' {extra} = proxy.get_object(_uuids[{idx}], "{panel_str}")'
+ )
+ extra_args = "".join(f", {e}" for e in extra_inputs)
+ script_lines.append(f" result = process(src{extra_args})")
+ script_lines.append(" proxy.add_object(result)")
+ script_lines.append(' print(f"Pipeline applied: {result.title}")')
+ script_lines.append("")
+
+ script = "\n".join(script_lines)
+ QW.QApplication.clipboard().setText(script)
+ if not execenv.unattended:
+ QW.QMessageBox.information(
+ panel.mainwindow,
+ _("Generate macro"),
+ _("Macro script copied to clipboard (%d actions).") % len(compute_actions),
+ )
+
+
+def delete_selected(panel: HistoryPanel) -> None:
+ """Delete the selected actions or sessions (with confirmation).
+
+ When a top-level session is selected, the entire session is deleted.
+ When individual actions are selected, they and all subsequent actions
+ in their parent session are removed. After deletion, the first
+ available item in the tree is selected automatically.
+ """
+ selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions)
+ if not selected:
+ return
+ has_individual_actions = any(isinstance(item, HistoryAction) for item in selected)
+ if has_individual_actions:
+ msg = _(
+ "Do you really want to delete the selected items?\n\n"
+ "Note: deleting an action also removes all subsequent "
+ "actions in the same session."
+ )
+ else:
+ msg = _("Do you really want to delete the selected items?")
+ reply = (
+ QW.QMessageBox.Yes
+ if execenv.unattended
+ else QW.QMessageBox.question(
+ panel.mainwindow,
+ _("Delete"),
+ msg,
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ QW.QMessageBox.No,
+ )
+ )
+ if reply != QW.QMessageBox.Yes:
+ return
+ sessions_to_remove: set[int] = set()
+ for item in selected:
+ if isinstance(item, HistorySession):
+ sessions_to_remove.add(id(item))
+ else:
+ # Individual action: remove from its parent session
+ for session in panel.history_sessions:
+ if item in session.actions:
+ session.remove_action(item)
+ if not session.actions:
+ sessions_to_remove.add(id(session))
+ break
+ panel.history_sessions = [
+ s for s in panel.history_sessions if id(s) not in sessions_to_remove
+ ]
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.update_actions_state()
+ # Auto-select the first available item after deletion
+ if panel.tree.topLevelItemCount() > 0:
+ first = panel.tree.topLevelItem(0)
+ panel.tree.setCurrentItem(first)
+ first.setSelected(True)
+
+
+def remove_incompatible_actions(panel: HistoryPanel) -> None:
+ """Remove all actions whose workspace state is incompatible.
+
+ Shows a confirmation dialog listing how many actions will be removed,
+ then purges them from their sessions. Empty sessions are also removed.
+ """
+ incompatible: list[tuple[HistorySession, HistoryAction]] = []
+ for session in panel.history_sessions:
+ for action in session.actions:
+ if not action.is_current_state_compatible(
+ panel.mainwindow, restore_selection=True
+ ):
+ incompatible.append((session, action))
+ if not incompatible:
+ if not execenv.unattended:
+ QW.QMessageBox.information(
+ panel.mainwindow,
+ _("Remove incompatible"),
+ _("All actions are compatible with the current workspace."),
+ )
+ return
+ reply = (
+ QW.QMessageBox.Yes
+ if execenv.unattended
+ else QW.QMessageBox.question(
+ panel.mainwindow,
+ _("Remove incompatible"),
+ _("%d incompatible action(s) will be removed. Continue?")
+ % len(incompatible),
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ QW.QMessageBox.No,
+ )
+ )
+ if reply != QW.QMessageBox.Yes:
+ return
+ for session, action in incompatible:
+ if action in session.actions:
+ session.actions.remove(action)
+ # Remove empty sessions
+ panel.history_sessions = [s for s in panel.history_sessions if s.actions]
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.update_actions_state()
diff --git a/datalab/gui/main.py b/datalab/gui/main.py
index 8c44cca1..cc98e81d 100644
--- a/datalab/gui/main.py
+++ b/datalab/gui/main.py
@@ -75,7 +75,7 @@
)
from datalab.gui.docks import DockablePlotWidget
from datalab.gui.h5io import H5InputOutput
-from datalab.gui.panel import base, image, macro, signal
+from datalab.gui.panel import base, history, image, macro, signal
from datalab.gui.pluginconfig import PluginConfigDialog
from datalab.gui.settings import AI_OPTION_NAMES, edit_settings
from datalab.objectmodel import ObjectGroup
@@ -178,6 +178,7 @@ def __init__(self, console=None, hide_on_close=False): # pylint: disable=too-ma
self.console: DockableConsole | None = None
self._startup_errors: list[str] = []
self.macropanel: MacroPanel | None = None
+ self.historypanel: history.HistoryPanel | None = None
self.aiassistantpanel = None # type: ignore[assignment]
self.main_toolbar: QW.QToolBar | None = None
@@ -197,6 +198,7 @@ def __init__(self, console=None, hide_on_close=False): # pylint: disable=too-ma
self.saveh5_action: QW.QAction | None = None
self.browseh5_action: QW.QAction | None = None
self.settings_action: QW.QAction | None = None
+ self.command_palette_action: QW.QAction | None = None
self.quit_action: QW.QAction | None = None
self.autorefresh_action: QW.QAction | None = None
self.showfirstonly_action: QW.QAction | None = None
@@ -735,12 +737,17 @@ def get_webapi_status(self) -> dict:
# ------Misc.
@property
def panels(self) -> tuple[AbstractPanel, ...]:
- """Return the tuple of implemented panels (signal, image)
+ """Return the tuple of implemented panels (signal, image, macro, history)
Returns:
Tuple of panels
"""
- return (self.signalpanel, self.imagepanel, self.macropanel)
+ return (
+ self.signalpanel,
+ self.imagepanel,
+ self.macropanel,
+ self.historypanel,
+ )
def __set_low_memory_state(self, state: bool) -> None:
"""Set memory warning state"""
@@ -1020,6 +1027,7 @@ def setup(self, console: bool = False) -> None:
self.__flush_startup_errors()
self.__update_actions(update_other_data_panel=True)
self.__add_macro_panel()
+ self.__add_history_panel()
self.__add_aiassistant_panel()
self.__configure_panels()
# Now that everything is set up, we can restore the window state:
@@ -1733,6 +1741,14 @@ def __add_macro_panel(self) -> None:
self.tabifyDockWidget(self.docks[self.imagepanel], mdock)
self.docks[self.signalpanel].raise_()
+ def __add_history_panel(self) -> None:
+ """Add history panel"""
+ self.historypanel = history.HistoryPanel(self)
+ hdock = self.__add_dockwidget(self.historypanel, _("History Panel"))
+ self.docks[self.historypanel] = hdock
+ self.tabifyDockWidget(self.docks[self.macropanel], hdock)
+ self.docks[self.signalpanel].raise_()
+
def __add_aiassistant_panel(self) -> None:
"""Add AI Assistant panel"""
# Local import to keep AI assistant fully optional/loadable on demand
@@ -2066,8 +2082,10 @@ def toggle_show_first_only(self, state: bool) -> None:
def reset_all(self) -> None:
"""Reset all application data"""
for panel in self.panels:
- if panel is not None:
+ if panel is not None and panel is not self.historypanel:
panel.remove_all_objects()
+ if self.historypanel is not None:
+ self.historypanel.start_new_session_after_workspace_reset()
@remote_controlled
def remove_object(self, force: bool = False) -> None:
@@ -2110,6 +2128,13 @@ def save_to_h5_file(self, filename=None) -> None:
)
if not filename:
return
+ self.historypanel.add_ui_entry(
+ _("Save to HDF5 file"),
+ target="mainwindow",
+ method_name="save_to_h5_file",
+ save_state=False,
+ filename=filename,
+ )
with qth.qt_try_loadsave_file(self, filename, "save"):
self.save_h5_workspace(filename)
@@ -2182,6 +2207,19 @@ def open_h5_files(
)
if not h5files:
return
+ if len(h5files) > 1:
+ entry_title = _("Open %d HDF5 files") % len(h5files)
+ else:
+ entry_title = _("Open HDF5 file")
+ self.historypanel.add_ui_entry(
+ entry_title,
+ target="mainwindow",
+ method_name="open_h5_files",
+ save_state=False,
+ h5files=h5files,
+ import_all=import_all,
+ reset_all=reset_all,
+ )
filenames, dsetnames = [], []
for fname_with_dset in h5files:
if "," in fname_with_dset:
diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py
index 9fde1651..46e46de9 100644
--- a/datalab/gui/panel/base.py
+++ b/datalab/gui/panel/base.py
@@ -9,6 +9,7 @@
from __future__ import annotations
import abc
+import copy
import glob
import os
import os.path as osp
@@ -90,6 +91,7 @@
get_number,
get_short_id,
get_uuid,
+ patch_title_with_ids,
set_number,
set_uuid,
)
@@ -207,6 +209,11 @@ def __init__(self, panel: BaseDataPanel, objclass: SignalObj | ImageObj) -> None
self.processing_param_editor: gdq.DataSetEditGroupBox | None = None
self.current_processing_obj: SignalObj | ImageObj | None = None
self.processing_scroll: QW.QScrollArea | None = None
+ # Auto-recompute toggle (session-only state, not persisted to Conf).
+ self.__auto_recompute_enabled: bool = False
+ self.__auto_recompute_timer = QC.QTimer(self)
+ self.__auto_recompute_timer.setSingleShot(True)
+ self.__auto_recompute_timer.timeout.connect(self.__auto_recompute_trigger)
# Properties tab
self.properties = gdq.DataSetEditGroupBox("", objclass)
@@ -725,6 +732,23 @@ def setup_processing_tab(
editor.SIG_APPLY_BUTTON_CLICKED.connect(self.apply_processing_parameters)
editor.set_apply_button_state(False)
+ # Hook into the per-edit change callback to support auto-recompute.
+ # ``DataSetEditLayout.change_callback`` is called whenever any widget
+ # value changes; wrap it so we can also (re)start the debounce timer.
+ try:
+ inner_layout = editor.edit # DataSetEditLayout instance
+ original_change_cb = inner_layout.change_callback
+
+ def _wrapped_change_cb() -> None:
+ if original_change_cb is not None:
+ original_change_cb()
+ if self.__auto_recompute_enabled:
+ self.__auto_recompute_timer.start(300)
+
+ inner_layout.change_callback = _wrapped_change_cb
+ except AttributeError:
+ pass
+
# Store reference to be able to retrieve it later
self.processing_param_editor = editor
@@ -751,7 +775,21 @@ def setup_processing_tab(
QW.QSizePolicy.Expanding, QW.QSizePolicy.Preferred
)
- self.processing_scroll.setWidget(editor)
+ # Build the tab content: editor + "Auto-recompute" checkbox.
+ container = QW.QWidget()
+ vbox = QW.QVBoxLayout(container)
+ vbox.setContentsMargins(0, 0, 0, 0)
+ vbox.addWidget(editor)
+ auto_cb = QW.QCheckBox(_("Auto-recompute on edit"), container)
+ auto_cb.setToolTip(
+ _("Automatically re-run processing when parameters are modified")
+ )
+ auto_cb.setChecked(self.__auto_recompute_enabled)
+ auto_cb.toggled.connect(self.__set_auto_recompute_enabled)
+ vbox.addWidget(auto_cb)
+ vbox.addStretch(1)
+
+ self.processing_scroll.setWidget(container)
self.tabwidget.insertTab(
insert_index,
self.processing_scroll,
@@ -781,6 +819,24 @@ def __get_processor_associated_to(
return self.panel.mainwindow.signalpanel.processor
return self.panel.mainwindow.imagepanel.processor
+ def __set_auto_recompute_enabled(self, enabled: bool) -> None:
+ """Toggle auto-recompute mode (session-only, not persisted)."""
+ self.__auto_recompute_enabled = bool(enabled)
+ if not self.__auto_recompute_enabled:
+ self.__auto_recompute_timer.stop()
+
+ def __auto_recompute_trigger(self) -> None:
+ """Debounced callback: push widget values then re-run processing."""
+ if not self.__auto_recompute_enabled:
+ return
+ editor = self.processing_param_editor
+ if editor is None:
+ return
+ # ``editor.set()`` synchronises widget values to the dataset and emits
+ # ``SIG_APPLY_BUTTON_CLICKED`` which is already wired to
+ # ``apply_processing_parameters``.
+ editor.set(check=False)
+
def apply_processing_parameters(
self, obj: SignalObj | ImageObj | None = None, interactive: bool = True
) -> ProcessingReport:
@@ -864,49 +920,116 @@ def apply_processing_parameters(
else:
report.success = True
- # Update the current object in-place with data from new object
- obj.title = new_obj.title
- if isinstance(obj, SignalObj):
- obj.xydata = new_obj.xydata
- else: # ImageObj
- obj.data = new_obj.data
- # Invalidate ROI mask cache when image dimensions may have changed
- # (the mask is computed based on image shape, so it must be recomputed)
- obj.invalidate_maskdata_cache()
-
- # Update metadata with new processing parameters
- updated_proc_params = ProcessingParameters(
- func_name=proc_params.func_name,
- pattern=proc_params.pattern,
- param=param,
- source_uuid=proc_params.source_uuid,
- )
- insert_processing_parameters(obj, updated_proc_params)
-
- # Auto-recompute analysis if the object had analysis parameters
- # Since the data has changed, any analysis results are now invalid
- # Use the processor for the current object's type (not source object's type)
- obj_processor = self.__get_processor_associated_to(obj)
- obj_processor.auto_recompute_analysis(obj)
-
- # Update the tree view item and refresh plot
- obj_uuid = get_uuid(obj)
- self.panel.objview.update_item(obj_uuid)
- self.panel.refresh_plot(obj_uuid, update_items=True, force=True)
-
- # Update the Properties tab to reflect the new object properties
- # (e.g., data type, dimensions, etc.)
- self.__update_properties_dataset(obj)
-
- # Refresh the Processing tab with the new parameters
- # Don't reset parameters from source object - keep the user's values
- # Set the Processing tab as current to keep it visible after refresh
- QC.QTimer.singleShot(
- 0,
- lambda: self.setup_processing_tab(
- obj, reset_params=False, set_current=True
- ),
- )
+ hpanel = getattr(self.panel.mainwindow, "historypanel", None)
+ is_edit_mode = hpanel is not None and hpanel.is_edit_mode()
+
+ if is_edit_mode:
+ # --- Edit mode: mutate obj in-place, cascade downstream ---
+
+ # Update the current object in-place with data from new object
+ obj.title = new_obj.title
+ if isinstance(obj, SignalObj):
+ obj.xydata = new_obj.xydata
+ else: # ImageObj
+ obj.data = new_obj.data
+ # Invalidate ROI mask cache when image dimensions may
+ # have changed (mask depends on image shape)
+ obj.invalidate_maskdata_cache()
+
+ # Update metadata with new processing parameters
+ updated_proc_params = ProcessingParameters(
+ func_name=proc_params.func_name,
+ pattern=proc_params.pattern,
+ param=param,
+ source_uuid=proc_params.source_uuid,
+ )
+ insert_processing_parameters(obj, updated_proc_params)
+
+ # Propagate the edited param to the History panel:
+ # Mutate the matching existing action (snapshot originals
+ # first), refresh its tree display, then cascade recompute
+ # to downstream actions so the chain stays consistent with
+ # the new parameters.
+ action = hpanel.find_action_for_output(
+ get_uuid(obj), proc_params.func_name
+ )
+ if action is not None:
+ action.snapshot_kwargs()
+ action.kwargs["param"] = copy.deepcopy(param)
+ hpanel.refresh_action(action)
+ hpanel.recompute_cascade(action)
+
+ # Auto-recompute analysis (data changed, results invalid)
+ obj_processor = self.__get_processor_associated_to(obj)
+ obj_processor.auto_recompute_analysis(obj)
+
+ # Update the tree view item and refresh plot
+ obj_uuid = get_uuid(obj)
+ self.panel.objview.update_item(obj_uuid)
+ self.panel.refresh_plot(obj_uuid, update_items=True, force=True)
+
+ # Update the Properties tab to reflect the new object
+ self.__update_properties_dataset(obj)
+ # Refresh the displayed processing history (Properties tab
+ # description) so the parameter change is visible immediately
+ self.display_processing_history(obj)
+
+ # Refresh the Processing tab with the new parameters
+ QC.QTimer.singleShot(
+ 0,
+ lambda: self.setup_processing_tab(
+ obj, reset_params=False, set_current=True
+ ),
+ )
+ else:
+ # --- Non-edit mode: create a new independent object ---
+
+ # Patch title with source object IDs (like menu-driven compute)
+ patch_title_with_ids(new_obj, [obj], get_short_id)
+
+ # Store processing metadata on the new object
+ # pylint: disable=import-outside-toplevel
+ from datalab.gui.processor.base import (
+ build_processing_parameters,
+ )
+
+ new_pp = build_processing_parameters(
+ proc_params.func_name,
+ proc_params.pattern,
+ param=copy.deepcopy(param),
+ source_uuid=proc_params.source_uuid,
+ )
+ insert_processing_parameters(new_obj, new_pp)
+
+ # Mark as freshly processed so the Processing tab is shown
+ self.mark_as_freshly_processed(new_obj)
+
+ # Add the new object to the same group as the source object
+ group_id = self.panel.objmodel.get_object_group_id(obj)
+ self.panel.add_object(new_obj, group_id=group_id, set_current=True)
+
+ # Record a brand-new history entry with the new object UUID
+ if hpanel is not None:
+ # Retrieve plugin_origin so replay without the plugin
+ # produces a rich error message (C3) instead of generic.
+ plugin_origin = None
+ try:
+ processor = self.__get_processor_associated_to(new_obj)
+ feature = processor.get_feature(proc_params.func_name)
+ plugin_origin = feature.plugin_origin
+ except (ValueError, AttributeError):
+ pass
+ hpanel.add_compute_entry_from_pp(
+ new_obj.title,
+ new_pp,
+ panel_str=self.panel.PANEL_STR_ID,
+ output_uuids=[get_uuid(new_obj)],
+ plugin_origin=plugin_origin,
+ )
+
+ # Auto-recompute analysis on the new object
+ obj_processor = self.__get_processor_associated_to(new_obj)
+ obj_processor.auto_recompute_analysis(new_obj)
if isinstance(obj, SignalObj):
report.message = _("Signal was reprocessed.")
@@ -932,6 +1055,7 @@ class AbstractPanel(QW.QSplitter, metaclass=AbstractPanelMeta):
H5_PREFIX = ""
SIG_OBJECT_ADDED = QC.Signal()
SIG_OBJECT_REMOVED = QC.Signal()
+ SIG_OBJECT_MODIFIED = QC.Signal()
@abc.abstractmethod
def __init__(self, parent):
@@ -1117,7 +1241,7 @@ def on_button_click(
""",
]
)
- NonModalInfoDialog(parent, "Pattern help", text).show()
+ NonModalInfoDialog(parent, _("Pattern help"), text).show()
def get_extension_choices(self, _item=None, _value=None):
"""Return list of available extensions for choice item."""
@@ -1258,7 +1382,7 @@ def on_help_button_click(
""",
]
)
- NonModalInfoDialog(parent, "Pattern help", text).show()
+ NonModalInfoDialog(parent, _("Pattern help"), text).show()
def get_conversion_choices(self, _item=None, _value=None):
"""Return list of available conversion choices."""
@@ -1592,6 +1716,7 @@ def set_object(self, obj: TypeObj) -> None:
# immediately if the modified object is currently selected.
self.objview.item_selection_changed()
self.refresh_plot("selected", update_items=True, force=True)
+ self.SIG_OBJECT_MODIFIED.emit()
def remove_all_objects(self) -> None:
"""Remove all objects"""
@@ -1718,6 +1843,12 @@ def duplicate_object(self) -> None:
"""Duplication signal/image object"""
if not self.mainwindow.confirm_memory_state():
return
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Duplicate object or group"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="duplicate_object",
+ save_state=False,
+ )
# Duplicate individual objects (exclusive with respect to groups)
for oid in self.objview.get_sel_object_uuids():
self.__duplicate_individual_obj(oid, set_current=False)
@@ -1732,6 +1863,12 @@ def duplicate_object(self) -> None:
def copy_metadata(self) -> None:
"""Copy object metadata"""
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Copy metadata"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="copy_metadata",
+ save_state=False,
+ )
obj = self.objview.get_sel_objects()[0]
self.metadata_clipboard = obj.metadata.copy()
@@ -1788,6 +1925,13 @@ def paste_metadata(self, param: PasteMetadataParam | None = None) -> None:
)
if not param.edit(parent=self.parentWidget()):
return
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Paste metadata"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="paste_metadata",
+ save_state=False,
+ param=param,
+ )
metadata = {}
if param.keep_roi and ROI_KEY in self.metadata_clipboard:
metadata[ROI_KEY] = self.metadata_clipboard[ROI_KEY]
@@ -1840,6 +1984,14 @@ def add_metadata(self, param: AddMetadataParam | None = None) -> None:
# Save settings to config
Conf.io.add_metadata_settings.set(param)
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Add metadata"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="add_metadata",
+ save_state=True,
+ param=param,
+ )
+
# Build values for all selected objects
values = param.build_values(sel_objects)
@@ -1852,19 +2004,49 @@ def add_metadata(self, param: AddMetadataParam | None = None) -> None:
"selected", update_items=True, only_visible=False, only_existing=True
)
- def copy_roi(self) -> None:
- """Copy regions of interest"""
- obj = self.objview.get_sel_objects()[0]
- self.__roi_clipboard = obj.roi.copy()
+ def copy_roi(self, roi_data=None) -> None:
+ """Copy regions of interest
+
+ Args:
+ roi_data: ROI snapshot for replay. When ``None`` (interactive use),
+ the ROI is read from the currently selected object.
+ """
+ if roi_data is None:
+ obj = self.objview.get_sel_objects()[0]
+ roi_data = obj.roi.copy()
+ self.__roi_clipboard = roi_data.copy()
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Copy regions of interest from selected %s")
+ % (_("signal") if self.PANEL_STR_ID == "signal" else _("image")),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="copy_roi",
+ save_state=True,
+ roi_data=roi_data,
+ )
- def paste_roi(self) -> None:
- """Paste regions of interest"""
+ def paste_roi(self, roi_data=None) -> None:
+ """Paste regions of interest
+
+ Args:
+ roi_data: ROI snapshot for replay. When ``None`` (interactive use),
+ the clipboard populated by :meth:`copy_roi` is used.
+ """
+ if roi_data is None:
+ roi_data = self.__roi_clipboard
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Paste regions of interest into selected %s")
+ % (_("signal") if self.PANEL_STR_ID == "signal" else _("image")),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="paste_roi",
+ save_state=True,
+ roi_data=roi_data,
+ )
sel_objects = self.objview.get_sel_objects(include_groups=True)
for obj in sel_objects:
if obj.roi is None:
- obj.roi = self.__roi_clipboard.copy()
+ obj.roi = roi_data.copy()
else:
- obj.roi = obj.roi.combine_with(self.__roi_clipboard)
+ obj.roi = obj.roi.combine_with(roi_data)
self.selection_changed(update_items=True)
self.refresh_plot(
"selected", update_items=True, only_visible=False, only_existing=True
@@ -1886,6 +2068,17 @@ def remove_object(self, force: bool = False) -> None:
)
if answer == QW.QMessageBox.No:
return
+ # IMPORTANT: save_state=True is required so that the selection of objects
+ # being deleted is captured. On replay, the captured selection (translated
+ # through uuid_remap) is restored before remove_object runs, ensuring that
+ # the correct object is removed instead of whatever is currently selected.
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Remove selected objects"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="remove_object",
+ save_state=True,
+ force=force,
+ )
sel_objects = self.objview.get_sel_objects(include_groups=True)
for obj in sorted(sel_objects, key=get_short_id, reverse=True):
dlg_list: list[QW.QDialog] = []
@@ -2011,6 +2204,14 @@ def new_group(self) -> None:
# Open a message box to enter the group name
group_name, ok = QW.QInputDialog.getText(self, _("New group"), _("Group name:"))
if ok:
+ self.mainwindow.historypanel.add_ui_entry(
+ _('New group "%s"') % group_name,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="add_group",
+ save_state=False,
+ title=group_name,
+ select=False,
+ )
self.add_group(group_name)
def rename_selected_object_or_group(self, new_name: str | None = None) -> None:
@@ -2019,6 +2220,13 @@ def rename_selected_object_or_group(self, new_name: str | None = None) -> None:
Args:
new_name: new name (default: None, i.e. ask user)
"""
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Rename selected object or group"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="rename_selected_object_or_group",
+ save_state=False,
+ new_name=new_name,
+ )
sel_objects = self.objview.get_sel_objects(include_groups=False)
sel_groups = self.objview.get_sel_groups()
if (not sel_objects and not sel_groups) or len(sel_objects) + len(
@@ -2095,6 +2303,13 @@ def set_current_object_title(self, title: str) -> None:
obj = self.objview.get_current_object()
obj.title = title
self.objview.update_item(get_uuid(obj))
+ self.mainwindow.historypanel.add_ui_entry(
+ _('Set current object title to "%s"') % title,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="set_current_object_title",
+ save_state=False,
+ title=title,
+ )
def __load_from_file(
self, filename: str, create_group: bool = True, add_objects: bool = True
@@ -2226,6 +2441,21 @@ def load_from_files(
filenames, _filt = getopenfilenames(self, _("Open"), basedir, filters)
# Sort filenames to ensure consistent alphabetical order across all platforms
filenames = sorted(filenames)
+ nbf = len(filenames)
+ if nbf > 1:
+ entry_title = _("Load from %d files") % nbf
+ else:
+ entry_title = _('Load "%s"') % osp.basename(filenames[0])
+ self.mainwindow.historypanel.add_ui_entry(
+ entry_title,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="load_from_files",
+ save_state=False,
+ filenames=filenames,
+ create_group=create_group,
+ add_objects=add_objects,
+ ignore_errors=ignore_errors,
+ )
objs = []
for filename in filenames:
with qt_try_loadsave_file(self.parentWidget(), filename, "load"):
@@ -2254,6 +2484,18 @@ def save_to_files(self, filenames: list[str] | str | None = None) -> None:
assert len(filenames) == len(objs), (
"Number of filenames must match number of objects"
)
+ nbf = len(filenames)
+ if nbf > 1:
+ entry_title = _("Save to %d different files") % nbf
+ else:
+ entry_title = _('Save to "%s"') % osp.basename(filenames[0])
+ self.mainwindow.historypanel.add_ui_entry(
+ entry_title,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="save_to_files",
+ save_state=False,
+ filenames=filenames,
+ )
for index, obj in enumerate(objs):
filename = filenames[index]
if filename is None:
@@ -2307,6 +2549,14 @@ def save_to_directory(self, param: SaveToDirectoryParam | None = None) -> None:
Conf.main.base_dir.set(param.directory)
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Save to directory"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="save_to_directory",
+ save_state=True,
+ param=param,
+ )
+
with create_progress_bar(self, _("Saving..."), max_=len(objs)) as progress:
for i, (path, obj) in enumerate(param.generate_filepath_obj_pairs(objs)):
progress.setValue(i + 1)
@@ -2540,16 +2790,19 @@ def properties_changed(self) -> None:
# Get only the properties that have changed from the original values
changed_props = self.objprop.get_changed_properties()
- # Apply only the changed properties to all selected objects
- for obj in self.objview.get_sel_objects(include_groups=True):
- obj.mark_roi_as_changed()
- # Update only the changed properties instead of all properties
- update_dataset(obj, changed_props)
- self.objview.update_item(get_uuid(obj))
+ # Apply only the changed properties to all selected objects.
+ # The ``replaying()`` guard suppresses the synthetic history entries
+ # that the auto-recompute below would otherwise create for each object.
+ with self.mainwindow.historypanel.replaying():
+ for obj in self.objview.get_sel_objects(include_groups=True):
+ obj.mark_roi_as_changed()
+ # Update only the changed properties instead of all properties
+ update_dataset(obj, changed_props)
+ self.objview.update_item(get_uuid(obj))
- # Auto-recompute analysis if the object had analysis parameters
- # Since properties have changed, any analysis results may now be invalid
- self.processor.auto_recompute_analysis(obj)
+ # Auto-recompute analysis if the object had analysis parameters
+ # Since properties have changed, any analysis results may now be invalid
+ self.processor.auto_recompute_analysis(obj)
# Refresh all selected items, including non-visible ones (only_visible=False)
# This ensures that plot items are updated for all selected objects, even if
@@ -2561,6 +2814,7 @@ def properties_changed(self) -> None:
# Update the stored original values to reflect the new state
# This ensures subsequent changes are compared against the current values
self.objprop.update_original_values()
+ self.SIG_OBJECT_MODIFIED.emit()
def recompute_processing(self) -> None:
"""Recompute/rerun selected objects or group with stored processing parameters.
@@ -2593,10 +2847,15 @@ def recompute_processing(self) -> None:
)
return
- # Recompute each object
- with create_progress_bar(
- self, _("Recomputing objects"), max_=len(recomputable_objects)
- ) as progress:
+ # Recompute each object -- silence history capture while doing so:
+ # the underlying compute_* methods would otherwise re-register
+ # synthetic entries for every recomputed object.
+ with (
+ self.mainwindow.historypanel.replaying(),
+ create_progress_bar(
+ self, _("Recomputing objects"), max_=len(recomputable_objects)
+ ) as progress,
+ ):
for index, obj in enumerate(recomputable_objects):
progress.setValue(index + 1)
QW.QApplication.processEvents()
@@ -3308,6 +3567,12 @@ def plot_results(
def delete_results(self) -> None:
"""Delete results"""
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Delete results"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="delete_results",
+ save_state=False,
+ )
objs = self.objview.get_sel_objects(include_groups=True)
rdatadict = create_resultdata_dict(objs)
if rdatadict:
@@ -3355,6 +3620,17 @@ def add_label_with_title(
added as an annotation, and that it can be edited or removed using the
annotation editing window.
"""
+ if title is None:
+ action_title = _("Add object title to plot")
+ else:
+ action_title = _("Add label with title")
+ self.mainwindow.historypanel.add_ui_entry(
+ action_title,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="add_label_with_title",
+ save_state=False,
+ title=title,
+ )
objs = self.objview.get_sel_objects(include_groups=True)
for obj in objs:
create_adapter_from_object(obj).add_label_with_title(title=title)
diff --git a/datalab/gui/panel/history/__init__.py b/datalab/gui/panel/history/__init__.py
new file mode 100644
index 00000000..4cce7abf
--- /dev/null
+++ b/datalab/gui/panel/history/__init__.py
@@ -0,0 +1,21 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""History panel subpackage — re-exports public history symbols."""
+
+from datalab.gui.panel.history.panel import HistoryPanel
+from datalab.history import HistoryAction, HistorySession, WorkspaceState
+from datalab.history.core import (
+ HISTORY_ACTION_SCHEMA_VERSION,
+ HISTORY_SCHEMA_VERSION,
+)
+from datalab.widgets.historytree import HistoryTree
+
+__all__ = [
+ "HISTORY_ACTION_SCHEMA_VERSION",
+ "HISTORY_SCHEMA_VERSION",
+ "HistoryAction",
+ "HistoryPanel",
+ "HistorySession",
+ "HistoryTree",
+ "WorkspaceState",
+]
diff --git a/datalab/gui/panel/history/chain.py b/datalab/gui/panel/history/chain.py
new file mode 100644
index 00000000..67d0906d
--- /dev/null
+++ b/datalab/gui/panel/history/chain.py
@@ -0,0 +1,377 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Action↔output chain helpers for the History panel."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.panel.history import recompute as hrec
+from datalab.gui.processor.base import (
+ ProcessingParameters,
+ extract_processing_parameters,
+ insert_processing_parameters,
+)
+from datalab.history import HistoryAction, HistorySession
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+_logger = logging.getLogger(__name__)
+
+
+def find_parent_session(
+ panel: HistoryPanel, action: HistoryAction
+) -> HistorySession | None:
+ """Return the session that contains ``action``, or None."""
+ for session in panel.history_sessions:
+ if action in session.actions:
+ return session
+ return None
+
+
+def resolve_panel_for_action(
+ panel: HistoryPanel, action: HistoryAction
+) -> BaseDataPanel | None:
+ """Return the data panel targeted by ``action``, or ``None``."""
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ return None
+ if action.panel_str == "signal":
+ return panel.mainwindow.signalpanel
+ if action.panel_str == "image":
+ return panel.mainwindow.imagepanel
+ return None
+
+
+def find_output_object_uuid(
+ panel: HistoryPanel, panel_data: BaseDataPanel, action: HistoryAction
+) -> str | None:
+ """Find the UUID of the output object produced by ``action`` in ``panel_data``.
+
+ Primary path: consult the bijective ``action_output_uuids`` mapping.
+ Fallback path: legacy heuristic on ``processing_parameters`` metadata.
+ """
+ registered = panel.action_output_uuids.get(action.uuid)
+ if registered:
+ existing_ids = set(panel_data.objmodel.get_object_ids())
+ for out_uuid in registered:
+ if out_uuid in existing_ids:
+ return out_uuid
+ if action.func_name is None:
+ return None
+ recorded_uuids = set(action.state.selection.get(panel_data.PANEL_STR_ID, []))
+ if not recorded_uuids:
+ return None
+ for obj in panel_data.objmodel:
+ pp = extract_processing_parameters(obj)
+ if pp is None or pp.func_name != action.func_name:
+ continue
+ if pp.source_uuid is not None and pp.source_uuid in recorded_uuids:
+ return get_uuid(obj)
+ if pp.source_uuids is not None and recorded_uuids.intersection(pp.source_uuids):
+ return get_uuid(obj)
+ return None
+
+
+def find_action_for_output(
+ panel: HistoryPanel, output_uuid: str, func_name: str
+) -> HistoryAction | None:
+ """Find the :class:`HistoryAction` that produced ``output_uuid``."""
+ if not panel.history_sessions:
+ return None
+ action_uuid = panel.output_to_action.get(output_uuid)
+ if action_uuid is not None:
+ mapped = next(
+ (
+ action
+ for session in panel.history_sessions
+ for action in session.actions
+ if action.uuid == action_uuid
+ ),
+ None,
+ )
+ if mapped is not None:
+ return mapped if mapped.func_name == func_name else None
+ panel_data: BaseDataPanel | None = None
+ output_obj = None
+ for p in (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel):
+ if p.objmodel.has_uuid(output_uuid):
+ output_obj = p.objmodel[output_uuid]
+ panel_data = p
+ break
+ if panel_data is None or output_obj is None:
+ return None
+ pp = extract_processing_parameters(output_obj)
+ if pp is None or pp.func_name != func_name or pp.source_uuid is None:
+ return None
+ target_source_uuid = pp.source_uuid
+ for current_session in reversed(panel.history_sessions):
+ for action in reversed(current_session.actions):
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ continue
+ if action.func_name != func_name:
+ continue
+ if action.panel_str != panel_data.PANEL_STR_ID:
+ continue
+ captured = action.state.selection.get(panel_data.PANEL_STR_ID, [])
+ if captured and captured[0] == target_source_uuid:
+ return action
+ return None
+
+
+def get_session_of(panel: HistoryPanel, action: HistoryAction) -> HistorySession | None:
+ """Return the session that contains ``action``, or None."""
+ for session in panel.history_sessions:
+ if action in session.actions:
+ return session
+ return None
+
+
+def action_output_uuid(panel: HistoryPanel, action: HistoryAction) -> str | None:
+ """Return the UUID of the object produced by ``action``, or ``None``."""
+ panel_data = resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ return None
+ return find_output_object_uuid(panel, panel_data, action)
+
+
+def action_consumes_any(action: HistoryAction, uuids: set[str]) -> bool:
+ """Return True if ``action``'s input UUIDs intersect ``uuids``."""
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ return False
+ pstr = action.panel_str or ""
+ captured: set[str] = set(action.state.selection.get(pstr, []))
+ obj2 = action.kwargs.get("obj2_uuids")
+ if obj2:
+ if isinstance(obj2, str):
+ captured.add(obj2)
+ else:
+ captured.update(obj2)
+ return bool(captured & uuids)
+
+
+def collect_downstream_uuids(panel: HistoryPanel, action: HistoryAction) -> set[str]:
+ """Return the transitive closure of output UUIDs descending from ``action``."""
+ if not panel.history_sessions:
+ return set()
+ current = get_session_of(panel, action)
+ if current is None:
+ return set()
+ root_out = action_output_uuid(panel, action)
+ if root_out is None:
+ return set()
+ closure: set[str] = {root_out}
+ idx = current.actions.index(action)
+ for downstream in current.actions[idx + 1 :]:
+ if downstream.kind != HistoryAction.KIND_COMPUTE:
+ continue
+ if not action_consumes_any(downstream, closure):
+ continue
+ out_uuid = action_output_uuid(panel, downstream)
+ if out_uuid is not None:
+ closure.add(out_uuid)
+ closure.discard(root_out)
+ return closure
+
+
+def get_downstream_actions(
+ panel: HistoryPanel, action: HistoryAction
+) -> list[HistoryAction]:
+ """Return the actions of the current session that depend on ``action``."""
+ if not panel.history_sessions:
+ return []
+ current = get_session_of(panel, action)
+ if current is None:
+ return []
+ root_out = action_output_uuid(panel, action)
+ if root_out is None:
+ return []
+ closure: set[str] = {root_out}
+ downstream: list[HistoryAction] = []
+ idx = current.actions.index(action)
+ for candidate in current.actions[idx + 1 :]:
+ if candidate.kind != HistoryAction.KIND_COMPUTE:
+ continue
+ if not action_consumes_any(candidate, closure):
+ continue
+ downstream.append(candidate)
+ out_uuid = action_output_uuid(panel, candidate)
+ if out_uuid is not None:
+ closure.add(out_uuid)
+ return downstream
+
+
+def resolve_target_outputs(
+ panel: HistoryPanel, panel_data: BaseDataPanel, action: HistoryAction
+) -> tuple[list[str], list[str]]:
+ """Return ``(existing, missing)`` UUIDs registered for ``action``."""
+ registered = list(panel.action_output_uuids.get(action.uuid, []))
+ existing_ids = set(panel_data.objmodel.get_object_ids())
+ existing: list[str] = [u for u in registered if u in existing_ids]
+ missing: list[str] = [u for u in registered if u not in existing_ids]
+ return existing, missing
+
+
+def existing_input_uuids(panel_data: BaseDataPanel, action: HistoryAction) -> list[str]:
+ """Return recorded input UUIDs that still exist in ``panel_data``."""
+ recorded = action.state.selection.get(panel_data.PANEL_STR_ID, [])
+ return [uuid for uuid in recorded if panel_data.objmodel.has_uuid(uuid)]
+
+
+def prune_output_mapping(panel: HistoryPanel) -> None:
+ """Drop entries of :attr:`output_to_action` whose object no longer exists."""
+ if not panel.output_to_action:
+ return
+ alive: set[str] = set()
+ for pdata in (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel):
+ alive.update(pdata.objmodel.get_object_ids())
+ stale = [u for u in panel.output_to_action if u not in alive]
+ for u in stale:
+ panel.output_to_action.pop(u, None)
+
+
+def rewrite_action_source(
+ action: HistoryAction,
+ pstr: str,
+ old_uuid: str,
+ new_uuid: str,
+) -> None:
+ """Replace ``old_uuid`` with ``new_uuid`` in an action's recorded inputs."""
+ sel = action.state.selection.get(pstr)
+ if sel:
+ action.state.selection[pstr] = [new_uuid if u == old_uuid else u for u in sel]
+ obj2 = action.kwargs.get("obj2_uuids")
+ if isinstance(obj2, str):
+ if obj2 == old_uuid:
+ action.kwargs["obj2_uuids"] = new_uuid
+ elif obj2:
+ action.kwargs["obj2_uuids"] = [new_uuid if u == old_uuid else u for u in obj2]
+
+
+def remove_single_action(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Remove a single action from its session (splice, not truncate)."""
+ for session in panel.history_sessions:
+ if action in session.actions:
+ session.actions.remove(action)
+ outs = panel.action_output_uuids.pop(action.uuid, [])
+ for out_uuid in outs:
+ if panel.output_to_action.get(out_uuid) == action.uuid:
+ panel.output_to_action.pop(out_uuid, None)
+ if not session.actions:
+ panel.history_sessions.remove(session)
+ break
+
+
+def reconnect_single_removed(
+ panel: HistoryPanel,
+ panel_data: BaseDataPanel,
+ x_uuid: str,
+ warnings: list[str],
+ roots_to_recompute: list[HistoryAction],
+) -> None:
+ """Reconnect consumers of a single deleted object ``x_uuid``."""
+ pstr = panel_data.PANEL_STR_ID
+ action_a = None
+ action_a_uuid = panel.output_to_action.get(x_uuid)
+ if action_a_uuid is not None:
+ for session in panel.history_sessions:
+ for a in session.actions:
+ if a.uuid == action_a_uuid:
+ action_a = a
+ break
+ if action_a is not None:
+ break
+ consumers: list[tuple[Any, Any]] = []
+ for obj in panel_data.objmodel:
+ pp = extract_processing_parameters(obj)
+ if pp is None:
+ continue
+ if pp.source_uuid == x_uuid or (pp.source_uuids and x_uuid in pp.source_uuids):
+ consumers.append((obj, pp))
+ if not consumers:
+ return
+ s_uuid: str | None = None
+ if action_a is not None:
+ sel = action_a.state.selection.get(pstr, [])
+ if sel:
+ s_uuid = sel[0]
+ alive_ids = set(panel_data.objmodel.get_object_ids())
+ if s_uuid is None or s_uuid not in alive_ids:
+ label = action_a.title or action_a.func_name if action_a is not None else x_uuid
+ warnings.append(
+ _(
+ "“%s” has dependent operations but no valid source to "
+ "reconnect to — downstream results are left unchanged."
+ )
+ % label
+ )
+ return
+ for obj, pp in consumers:
+ new_source_uuid = s_uuid if pp.source_uuid == x_uuid else pp.source_uuid
+ new_source_uuids = pp.source_uuids
+ if pp.source_uuids and x_uuid in pp.source_uuids:
+ new_source_uuids = [s_uuid if u == x_uuid else u for u in pp.source_uuids]
+ insert_processing_parameters(
+ obj,
+ ProcessingParameters(
+ func_name=pp.func_name,
+ pattern=pp.pattern,
+ param=pp.param,
+ source_uuid=new_source_uuid,
+ source_uuids=new_source_uuids,
+ ),
+ )
+ if pp.func_name:
+ action_b = find_action_for_output(panel, get_uuid(obj), pp.func_name)
+ if action_b is not None:
+ rewrite_action_source(action_b, pstr, x_uuid, s_uuid)
+ if action_b not in roots_to_recompute:
+ roots_to_recompute.append(action_b)
+ if action_a is not None:
+ outs = panel.action_output_uuids.get(action_a.uuid, [])
+ if not any(o in alive_ids for o in outs):
+ remove_single_action(panel, action_a)
+
+
+def reconnect_chain_after_removal(
+ panel: HistoryPanel, panel_data: BaseDataPanel
+) -> None:
+ """Reconnect the processing chain after object(s) were deleted from a data panel."""
+ pstr = panel_data.PANEL_STR_ID
+ previous = panel.obj_ids_snapshot.get(pstr, set())
+ current = set(panel_data.objmodel.get_object_ids())
+ removed = previous - current
+ if not removed or panel.reconnecting:
+ return
+ panel.reconnecting = True
+ try:
+ warnings: list[str] = []
+ roots_to_recompute: list[HistoryAction] = []
+ for x_uuid in removed:
+ reconnect_single_removed(
+ panel, panel_data, x_uuid, warnings, roots_to_recompute
+ )
+ for action in roots_to_recompute:
+ hrec.recompute_action_in_place(panel, action)
+ hrec.recompute_cascade(panel, action)
+ if warnings and not execenv.unattended:
+ QW.QMessageBox.warning(
+ panel.mainwindow,
+ _("Delete"),
+ _("Some operations could not be reconnected after deletion:")
+ + "\n\n• "
+ + "\n• ".join(warnings),
+ )
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.update_actions_state()
+ finally:
+ panel.reconnecting = False
+ panel.refresh_obj_ids_snapshot()
diff --git a/datalab/gui/panel/history/interactive_replay.py b/datalab/gui/panel/history/interactive_replay.py
new file mode 100644
index 00000000..07fa3735
--- /dev/null
+++ b/datalab/gui/panel/history/interactive_replay.py
@@ -0,0 +1,194 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Interactive (dialog-driven) replay helpers for the History panel."""
+
+from __future__ import annotations
+
+import copy
+import logging
+from typing import TYPE_CHECKING
+
+import guidata.dataset as gds
+from guidata.dataset.qtwidgets import DataSetEditDialog, DataSetGroupEditDialog
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.panel.history import recompute as hrec
+from datalab.history import HistoryAction, HistorySession
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+_logger = logging.getLogger(__name__)
+
+
+def replay_restore_actions(
+ panel: HistoryPanel, replay: bool = True, restore_selection: bool = True
+) -> None:
+ """Replay and/or restore selection for the selected actions."""
+ panel.refresh_compatibility_items()
+ selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions)
+ if not selected:
+ if not panel.history_sessions:
+ return
+ selected = [panel.history_sessions[-1]]
+ for session_or_action in selected:
+ if isinstance(session_or_action, HistoryAction) and session_or_action.is_stale:
+ hrec.recompute_cascade(panel, session_or_action)
+ continue
+ if not session_or_action.is_current_state_compatible(
+ panel.mainwindow, restore_selection=restore_selection
+ ):
+ if not execenv.unattended:
+ QW.QMessageBox.critical(
+ panel.mainwindow,
+ _("Error"),
+ _("The current workspace state is not compatible with the action."),
+ )
+ return
+ if replay:
+ if panel.edit_mode and isinstance(session_or_action, HistoryAction):
+ edit_mode_replay(panel, session_or_action)
+ elif panel.edit_mode and isinstance(session_or_action, HistorySession):
+ view_only_session_replay(panel, session_or_action, restore_selection)
+ else:
+ with panel.replaying(), panel.output_suppressed():
+ session_or_action.replay(
+ panel.mainwindow,
+ restore_selection=restore_selection,
+ edit=panel.edit_mode,
+ )
+ elif restore_selection:
+ if panel.edit_mode or panel.has_any_pending_edits():
+ restore_action_params(panel, session_or_action)
+ else:
+ session_or_action.restore(panel.mainwindow)
+
+
+def prompt_edit_action_params(
+ panel: HistoryPanel, action: HistoryAction
+) -> bool | None:
+ """Open the parameter dialog for *action* according to its pattern."""
+ pattern = action.pattern
+ if pattern in {"1_to_1", "1_to_0", "n_to_1", "2_to_1"}:
+ param = action.kwargs.get("param")
+ if param is None:
+ return None
+ edited = copy.deepcopy(param)
+ dialog_target: gds.DataSet | gds.DataSetGroup = edited
+ new_kwargs = {"param": edited}
+ elif pattern == "1_to_n":
+ params = action.kwargs.get("params") or []
+ if not params:
+ return None
+ edited_params = [copy.deepcopy(p) for p in params]
+ dialog_target = gds.DataSetGroup(edited_params, title=_("Parameters"))
+ new_kwargs = {"params": edited_params}
+ else:
+ return None
+ if not dialog_target.edit(parent=panel.mainwindow):
+ return False
+ action.snapshot_kwargs()
+ action.kwargs.update(new_kwargs)
+ return True
+
+
+def edit_mode_replay(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Replay a single action in edit mode: open param dialog, update kwargs."""
+ if action.kind != HistoryAction.KIND_COMPUTE or action.pattern is None:
+ with panel.replaying(), panel.output_suppressed():
+ action.replay(panel.mainwindow, restore_selection=True, edit=True)
+ return
+
+ chain: list[HistoryAction] = [action] + panel.get_downstream_actions(action)
+ edited_actions: list[HistoryAction] = []
+ for a in chain:
+ result = prompt_edit_action_params(panel, a)
+ if result is False:
+ for done in edited_actions:
+ done.restore_kwargs()
+ panel.tree.refresh_action_item(done)
+ return
+ if result is True:
+ edited_actions.append(a)
+
+ for a in edited_actions:
+ panel.tree.refresh_action_item(a)
+
+ downstream = chain[1:]
+ hrec.recompute_action_in_place(panel, action)
+ hrec.recompute_cascade(panel, action, descendants=downstream)
+
+ for a in chain:
+ panel.tree.refresh_action_item(a)
+ QW.QApplication.processEvents()
+
+
+def show_readonly_param_dialog(
+ panel: HistoryPanel, dataset: gds.DataSet | gds.DataSetGroup
+) -> None:
+ """Show a parameter dialog identical to the edit dialog but read-only."""
+ if isinstance(dataset, gds.DataSetGroup):
+ dialog = DataSetGroupEditDialog(dataset, parent=panel.mainwindow)
+ else:
+ dialog = DataSetEditDialog(dataset, parent=panel.mainwindow)
+ for edl in dialog.edit_layout:
+ for widget in edl.widgets:
+ if widget.group is not None:
+ widget.group.setEnabled(False)
+ if widget.label is not None:
+ widget.label.setEnabled(False)
+ dialog.exec()
+
+
+def view_only_session_replay(
+ panel: HistoryPanel,
+ session: HistorySession,
+ restore_selection: bool,
+) -> None:
+ """Replay a session in edit mode with read-only parameter dialogs."""
+ for action in session.actions:
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ continue
+ pattern = action.pattern
+ panel.select_action_in_tree(action)
+ QW.QApplication.processEvents()
+ if pattern in {"1_to_1", "1_to_0", "n_to_1", "2_to_1"}:
+ param = action.kwargs.get("param")
+ if param is not None:
+ show_readonly_param_dialog(panel, copy.deepcopy(param))
+ elif pattern == "1_to_n":
+ params = action.kwargs.get("params") or []
+ if params:
+ group = gds.DataSetGroup(
+ [copy.deepcopy(p) for p in params],
+ title=_("Parameters"),
+ )
+ show_readonly_param_dialog(panel, group)
+
+ with panel.replaying(), panel.output_suppressed():
+ session.replay(
+ panel.mainwindow,
+ restore_selection=restore_selection,
+ edit=False,
+ )
+
+
+def restore_action_params(
+ panel: HistoryPanel, item: HistoryAction | HistorySession
+) -> None:
+ """Restore original kwargs from snapshot and recompute in-place."""
+ actions: list[HistoryAction]
+ if isinstance(item, HistorySession):
+ actions = [a for a in item.actions if a.kind == HistoryAction.KIND_COMPUTE]
+ else:
+ actions = [item]
+ for action in actions:
+ if not action.has_pending_edits:
+ continue
+ action.restore_kwargs()
+ panel.tree.refresh_action_item(action)
+ hrec.recompute_action_in_place(panel, action)
+ hrec.recompute_cascade(panel, action)
+ panel.update_actions_state()
diff --git a/datalab/gui/panel/history/panel.py b/datalab/gui/panel/history/panel.py
new file mode 100644
index 00000000..e4c74b72
--- /dev/null
+++ b/datalab/gui/panel/history/panel.py
@@ -0,0 +1,884 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+.. History panel (see parent package :mod:`datalab.gui.panel`)
+"""
+
+from __future__ import annotations
+
+import functools
+import logging
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, Any, Callable, Generator
+
+from guidata.configtools import get_icon
+from guidata.qthelpers import add_actions, create_action
+from guidata.widgets.dockable import DockableWidgetMixin
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+
+from datalab.config import Conf, _
+from datalab.env import execenv
+from datalab.gui import historysession_ops as hsess
+from datalab.gui import historytools_ops as htools
+from datalab.gui.panel.base import AbstractPanel
+from datalab.gui.panel.history import chain as hchain
+from datalab.gui.panel.history import interactive_replay as hreplay
+from datalab.gui.panel.history import recompute as hrec
+from datalab.h5 import history as hio
+from datalab.history import HistoryAction, HistorySession
+from datalab.widgets.historytree import HistoryTree
+from datalab.widgets.workspacestate_widget import WorkspaceStateWidget
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.h5.native import NativeH5Reader, NativeH5Writer
+
+_logger = logging.getLogger(__name__)
+
+
+class HistoryPanel(AbstractPanel, DockableWidgetMixin):
+ """History panel"""
+
+ LOCATION = QC.Qt.RightDockWidgetArea
+ PANEL_STR = _("History panel")
+
+ H5_PREFIX = "DataLab_His"
+
+ SIG_OBJECT_MODIFIED = QC.Signal()
+
+ FILE_FILTERS = f"{_('History files')} (*.dlhist)"
+
+ def __init__(self, parent: DLMainWindow) -> None:
+ super().__init__(parent)
+ self.setWindowTitle(self.PANEL_STR)
+ self.setWindowIcon(get_icon("history.svg"))
+ self.setOrientation(QC.Qt.Vertical)
+
+ self._record_mode = False
+ self.edit_mode = False
+ self._replaying = False
+ self._output_suppressed = False
+ self._syncing = False
+ self.cascade_in_progress = False
+ self._delete_action: QW.QAction | None = None
+ self._duplicate_action: QW.QAction | None = None
+ self.step_prev_action: QW.QAction | None = None
+ self.step_next_action: QW.QAction | None = None
+ self._restore_selection_action: QW.QAction | None = None
+ self._edit_action: QW.QAction | None = None
+ self._record_action: QW.QAction | None = None
+ self._menu_actions: list[QW.QAction] = self.create_menu_actions()
+
+ self.mainwindow = parent
+ self.tree = HistoryTree(self)
+ self.tree.customContextMenuRequested.connect(self.show_context_menu)
+ self.tree.itemDoubleClicked.connect(self.replay_restore_actions)
+ self.tree.itemSelectionChanged.connect(self.sync_panel_selection)
+ self.tree.itemSelectionChanged.connect(self.update_actions_state)
+ self.tree.itemSelectionChanged.connect(self.update_state_widget)
+
+ self._state_widget = WorkspaceStateWidget(self)
+
+ toolbar = QW.QToolBar(self)
+ add_actions(toolbar, self._menu_actions)
+ widget = QW.QWidget(self)
+ layout = QW.QVBoxLayout()
+ layout.addWidget(toolbar)
+ layout.addWidget(self.tree)
+ layout.addWidget(self._state_widget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ widget.setLayout(layout)
+
+ self.addWidget(widget)
+
+ self.history_sessions: list[HistorySession] = []
+ self._session_increment = 0
+ self.action_output_uuids: dict[str, list[str]] = {}
+ self.output_to_action: dict[str, str] = {}
+ self.cascade_warnings: list[str] = []
+ self.broken_actions: set[str] = set()
+ self.reconnecting = False
+ self.obj_ids_snapshot: dict[str, set[str]] = {}
+ for panel in (self.mainwindow.signalpanel, self.mainwindow.imagepanel):
+ panel.SIG_OBJECT_ADDED.connect(self.refresh_compatibility_items)
+ panel.SIG_OBJECT_ADDED.connect(self.refresh_obj_ids_snapshot)
+ panel.SIG_OBJECT_REMOVED.connect(self.refresh_compatibility_items)
+ panel.SIG_OBJECT_REMOVED.connect(
+ functools.partial(self.reconnect_chain_after_removal, panel)
+ )
+ panel.SIG_OBJECT_REMOVED.connect(self.prune_output_mapping)
+ panel.SIG_OBJECT_MODIFIED.connect(self.refresh_compatibility_items)
+ self.refresh_obj_ids_snapshot()
+ self.update_actions_state()
+ self.refresh_compatibility_items()
+ if not execenv.unattended and Conf.proc.history_auto_record.get(True):
+ self._record_action.setChecked(True)
+ self.create_new_session()
+
+ def refresh_obj_ids_snapshot(self) -> None:
+ """Cache the current object ids of both data panels."""
+ self.obj_ids_snapshot = {
+ self.mainwindow.signalpanel.PANEL_STR_ID: set(
+ self.mainwindow.signalpanel.objmodel.get_object_ids()
+ ),
+ self.mainwindow.imagepanel.PANEL_STR_ID: set(
+ self.mainwindow.imagepanel.objmodel.get_object_ids()
+ ),
+ }
+
+ def update_actions_state(self) -> None:
+ """Update the enabled state of menu actions depending on history content."""
+ has_history = len(self) > 0
+ for action in (self._delete_action, self._duplicate_action):
+ if action is not None:
+ action.setEnabled(has_history)
+ if self.step_prev_action is not None:
+ self.step_prev_action.setEnabled(self.can_step_prev())
+ if self.step_next_action is not None:
+ self.step_next_action.setEnabled(self.can_step_next())
+ if self._restore_selection_action is not None:
+ self._restore_selection_action.setEnabled(
+ self.edit_mode or self.has_any_pending_edits()
+ )
+
+ @property
+ def session_increment(self) -> int:
+ """Return the current session counter."""
+ return self._session_increment
+
+ @session_increment.setter
+ def session_increment(self, value: int) -> None:
+ """Set the current session counter."""
+ self._session_increment = value
+
+ @property
+ def record_mode_enabled(self) -> bool:
+ """Return True when record mode is enabled."""
+ return self._record_mode
+
+ def has_any_pending_edits(self) -> bool:
+ """Return True if any action across all sessions has a pending Edit
+ mode snapshot (i.e. uncommitted edits that Restore can revert)."""
+ return any(
+ action.has_pending_edits
+ for session in self.history_sessions
+ for action in session.actions
+ )
+
+ def update_state_widget(self) -> None:
+ """Update the workspace state widget from the currently selected action."""
+ action = self.current_action()
+ if action is not None:
+ self._state_widget.update_from_state(action.state)
+ else:
+ self._state_widget.update_from_state(None)
+
+ def create_menu_actions(self) -> list[QW.QAction]:
+ """Create menu actions for the history panel."""
+ edit_action = create_action(
+ self,
+ _("Edit mode"),
+ toggled=self.toggle_edit_mode,
+ icon=get_icon("edit_mode.svg"),
+ )
+ edit_action.setChecked(self.edit_mode)
+ self._edit_action = edit_action
+ record_action = create_action(
+ self,
+ _("Record mode"),
+ toggled=self.toggle_record_mode,
+ icon=get_icon("record.svg"),
+ )
+ record_action.setChecked(self._record_mode)
+ self._record_action = record_action
+ new_session_action = create_action(
+ self,
+ _("New session"),
+ self.create_new_session,
+ icon=get_icon("libre-gui-add.svg"),
+ tip=_("Start a new history session"),
+ )
+ open_action = create_action(
+ self,
+ _("Open history file..."),
+ triggered=lambda checked=False: self.open_dlhist_file(),
+ icon=get_icon("fileopen_h5.svg"),
+ tip=_("Open history from a standalone .dlhist file"),
+ )
+ save_action = create_action(
+ self,
+ _("Save history file..."),
+ triggered=lambda checked=False: self.save_to_dlhist_file(),
+ icon=get_icon("filesave_h5.svg"),
+ tip=_("Save history to a standalone .dlhist file"),
+ )
+ self._delete_action = create_action(
+ self,
+ _("Delete"),
+ self.delete_selected,
+ icon=get_icon("delete.svg"),
+ )
+ self._duplicate_action = create_action(
+ self,
+ _("Duplicate"),
+ self.duplicate_selected_entries,
+ icon=get_icon("duplicate.svg"),
+ tip=_("Duplicate selected history action/session"),
+ )
+ self.step_prev_action = create_action(
+ self,
+ _("Previous step"),
+ triggered=self.step_prev,
+ icon=get_icon("libre-gui-arrow-left.svg"),
+ tip=_("Select the previous action in the current session"),
+ shortcut=QG.QKeySequence("Ctrl+Left"),
+ )
+ self.step_next_action = create_action(
+ self,
+ _("Next step"),
+ triggered=self.step_next,
+ icon=get_icon("libre-gui-arrow-right.svg"),
+ tip=_("Select the next action in the current session"),
+ shortcut=QG.QKeySequence("Ctrl+Right"),
+ )
+ generate_macro_action = create_action(
+ self,
+ _("Generate macro"),
+ self.generate_macro,
+ icon=get_icon("console.svg"),
+ tip=_("Generate a Python macro script from history"),
+ )
+ remove_incompatible_action = create_action(
+ self,
+ _("Remove incompatible"),
+ self.remove_incompatible_actions,
+ icon=get_icon("edit/delete_all.svg"),
+ tip=_("Remove actions incompatible with the current workspace"),
+ )
+ self._restore_selection_action = create_action(
+ self,
+ _("Restore parameters"),
+ lambda: self.replay_restore_actions(restore_selection=True, replay=False),
+ icon=get_icon("restore_selection.svg"),
+ tip=_("Restore original parameters (discard edit-mode changes)"),
+ )
+ return [
+ record_action,
+ new_session_action,
+ None,
+ open_action,
+ save_action,
+ None,
+ self.step_prev_action,
+ self.step_next_action,
+ None,
+ create_action(
+ self,
+ _("Replay"),
+ lambda: self.replay_restore_actions(restore_selection=False),
+ icon=get_icon("replay.svg"),
+ ),
+ self._restore_selection_action,
+ edit_action,
+ None,
+ self._duplicate_action,
+ generate_macro_action,
+ None,
+ remove_incompatible_action,
+ self._delete_action,
+ ]
+
+ def toggle_edit_mode(self, checked: bool) -> None:
+ """Toggle edit mode.
+
+ Toggling Edit mode off is a definitive commit: all parameter
+ changes performed during the session become permanent.
+ """
+ if not checked and self.has_any_pending_edits():
+ reply = (
+ QW.QMessageBox.Yes
+ if execenv.unattended
+ else QW.QMessageBox.question(
+ self.mainwindow,
+ _("Commit edit mode changes?"),
+ _(
+ "You are about to exit Edit mode.\n\n"
+ "All parameter changes made during this session will be "
+ "permanently kept.\n"
+ "This action cannot be undone — Restore will no longer "
+ "be available.\n\n"
+ "Do you want to continue?"
+ ),
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ QW.QMessageBox.No,
+ )
+ )
+ if reply != QW.QMessageBox.Yes:
+ if self._edit_action is not None:
+ self._edit_action.blockSignals(True)
+ self._edit_action.setChecked(True)
+ self._edit_action.blockSignals(False)
+ return
+ self.edit_mode = checked
+ if not checked:
+ for session in self.history_sessions:
+ for action in session.actions:
+ action.discard_snapshot()
+ self.update_actions_state()
+
+ def toggle_record_mode(self, checked: bool) -> None:
+ """Toggle record mode."""
+ self._record_mode = checked
+
+ def is_edit_mode(self) -> bool:
+ """Return True when the History panel is in edit mode."""
+ return self.edit_mode
+
+ @contextmanager
+ def replaying(self) -> Generator[None, None, None]:
+ """Context manager suppressing history capture during its scope."""
+ previous = self._replaying
+ self._replaying = True
+ try:
+ yield
+ finally:
+ self._replaying = previous
+
+ def is_replaying(self) -> bool:
+ """Return True when an external replay/recompute is in progress."""
+ return self._replaying
+
+ @contextmanager
+ def output_suppressed(self) -> Generator[None, None, None]:
+ """Context manager suppressing compute outputs during its scope."""
+ previous = self._output_suppressed
+ self._output_suppressed = True
+ try:
+ yield
+ finally:
+ self._output_suppressed = previous
+
+ def is_output_suppressed(self) -> bool:
+ """Return True when compute outputs must not be added to panels."""
+ return self._output_suppressed
+
+ def show_context_menu(self, pos: QC.QPoint) -> None:
+ """Show the context menu."""
+ self.refresh_compatibility_items()
+ menu = QW.QMenu()
+ add_actions(menu, self._menu_actions)
+ menu.exec_(self.tree.mapToGlobal(pos))
+
+ def get_action_from_uuid(self, uuid: str) -> HistoryAction:
+ """Get the action from its UUID."""
+ for session in self.history_sessions:
+ for action in session.actions:
+ if action.uuid == uuid:
+ return action
+ raise ValueError("Action not found")
+
+ # ------------------------------------------------------------------
+ # Interactive replay delegations
+ # ------------------------------------------------------------------
+
+ def replay_restore_actions(
+ self, replay: bool = True, restore_selection: bool = True
+ ) -> None:
+ """Replay and/or restore selection for the selected actions."""
+ return hreplay.replay_restore_actions(self, replay, restore_selection)
+
+ def prompt_edit_action_params(self, action: HistoryAction) -> bool | None:
+ """Open the parameter dialog for *action* according to its pattern."""
+ return hreplay.prompt_edit_action_params(self, action)
+
+ def edit_mode_replay(self, action: HistoryAction) -> None:
+ """Replay a single action in edit mode."""
+ return hreplay.edit_mode_replay(self, action)
+
+ def show_readonly_param_dialog(self, dataset: Any) -> None:
+ """Show a parameter dialog identical to the edit dialog but read-only."""
+ return hreplay.show_readonly_param_dialog(self, dataset)
+
+ def view_only_session_replay(
+ self, session: HistorySession, restore_selection: bool
+ ) -> None:
+ """Replay a session in edit mode with read-only parameter dialogs."""
+ return hreplay.view_only_session_replay(self, session, restore_selection)
+
+ def restore_action_params(self, item: HistoryAction | HistorySession) -> None:
+ """Restore original kwargs from snapshot and recompute in-place."""
+ return hreplay.restore_action_params(self, item)
+
+ # ------------------------------------------------------------------
+ # Chain delegations
+ # ------------------------------------------------------------------
+
+ def find_parent_session(self, action: HistoryAction) -> HistorySession | None:
+ """Return the session that contains ``action``, or None."""
+ return hchain.find_parent_session(self, action)
+
+ def resolve_panel_for_action(self, action: HistoryAction) -> BaseDataPanel | None:
+ """Return the data panel targeted by ``action``, or ``None``."""
+ return hchain.resolve_panel_for_action(self, action)
+
+ def find_output_object_uuid(
+ self, panel: BaseDataPanel, action: HistoryAction
+ ) -> str | None:
+ """Find the UUID of the output object produced by ``action``."""
+ return hchain.find_output_object_uuid(self, panel, action)
+
+ def find_action_for_output(
+ self, output_uuid: str, func_name: str
+ ) -> HistoryAction | None:
+ """Find the action that produced ``output_uuid``."""
+ return hchain.find_action_for_output(self, output_uuid, func_name)
+
+ def get_session_of(self, action: HistoryAction) -> HistorySession | None:
+ """Return the session that contains ``action``, or None."""
+ return hchain.get_session_of(self, action)
+
+ def action_output_uuid(self, action: HistoryAction) -> str | None:
+ """Return the UUID of the object produced by ``action``, or ``None``."""
+ return hchain.action_output_uuid(self, action)
+
+ def action_consumes_any(self, action: HistoryAction, uuids: set[str]) -> bool:
+ """Return True if ``action``'s input UUIDs intersect ``uuids``."""
+ return hchain.action_consumes_any(action, uuids)
+
+ def collect_downstream_uuids(self, action: HistoryAction) -> set[str]:
+ """Return the transitive closure of output UUIDs descending from ``action``."""
+ return hchain.collect_downstream_uuids(self, action)
+
+ def get_downstream_actions(self, action: HistoryAction) -> list[HistoryAction]:
+ """Return the actions of the current session that depend on ``action``."""
+ return hchain.get_downstream_actions(self, action)
+
+ def resolve_target_outputs(
+ self, panel: BaseDataPanel, action: HistoryAction
+ ) -> tuple[list[str], list[str]]:
+ """Return ``(existing, missing)`` UUIDs registered for ``action``."""
+ return hchain.resolve_target_outputs(self, panel, action)
+
+ def existing_input_uuids(
+ self, panel: BaseDataPanel, action: HistoryAction
+ ) -> list[str]:
+ """Return recorded input UUIDs that still exist in ``panel``."""
+ return hchain.existing_input_uuids(panel, action)
+
+ def prune_output_mapping(self) -> None:
+ """Drop entries of :attr:`output_to_action` whose object no longer exists."""
+ return hchain.prune_output_mapping(self)
+
+ def rewrite_action_source(
+ self,
+ action: HistoryAction,
+ pstr: str,
+ old_uuid: str,
+ new_uuid: str,
+ ) -> None:
+ """Replace ``old_uuid`` with ``new_uuid`` in an action's recorded inputs."""
+ return hchain.rewrite_action_source(action, pstr, old_uuid, new_uuid)
+
+ def remove_single_action(self, action: HistoryAction) -> None:
+ """Remove a single action from its session (splice, not truncate)."""
+ return hchain.remove_single_action(self, action)
+
+ def reconnect_single_removed(
+ self,
+ panel: BaseDataPanel,
+ x_uuid: str,
+ warnings: list[str],
+ roots_to_recompute: list[HistoryAction],
+ ) -> None:
+ """Reconnect consumers of a single deleted object ``x_uuid``."""
+ return hchain.reconnect_single_removed(
+ self, panel, x_uuid, warnings, roots_to_recompute
+ )
+
+ def reconnect_chain_after_removal(self, panel: BaseDataPanel) -> None:
+ """Reconnect the processing chain after object(s) were deleted."""
+ return hchain.reconnect_chain_after_removal(self, panel)
+
+ # ------------------------------------------------------------------
+ # Recompute delegations
+ # ------------------------------------------------------------------
+
+ def refresh_action(self, action: HistoryAction) -> None:
+ """Refresh the tree display for ``action`` after its kwargs were mutated."""
+ return hrec.refresh_action(self, action)
+
+ def update_obj_in_place(self, target_obj: Any, new_obj: Any) -> None:
+ """Copy data + title + metadata from ``new_obj`` onto ``target_obj``."""
+ return hrec.update_obj_in_place(target_obj, new_obj)
+
+ def refresh_target(self, panel: BaseDataPanel, output_uuid: str) -> None:
+ """Refresh tree item + plot for ``output_uuid`` in ``panel``."""
+ return hrec.refresh_target(panel, output_uuid)
+
+ def record_missing_outputs(self, action: HistoryAction, missing: list[str]) -> None:
+ """Log + queue a user-facing warning for deleted output objects."""
+ return hrec.record_missing_outputs(self, action, missing)
+
+ def recompute_action_in_place(self, action: HistoryAction) -> None:
+ """Re-run ``action`` on the existing output object(s) (same UUIDs)."""
+ return hrec.recompute_action_in_place(self, action)
+
+ def handle_missing_feature(self, action: HistoryAction, exc: Any) -> None:
+ """Flag ``action`` as broken (missing plugin) and queue a user warning."""
+ return hrec.handle_missing_feature(self, action, exc)
+
+ def recompute_1_to_1_in_place(self, action: HistoryAction) -> None:
+ """Recompute a single 1-to-1 action in place."""
+ return hrec.recompute_1_to_1_in_place(self, action)
+
+ def recompute_1_to_n_in_place(self, action: HistoryAction) -> None:
+ """Recompute a 1-to-n action in place."""
+ return hrec.recompute_1_to_n_in_place(self, action)
+
+ def recompute_n_to_1_in_place(self, action: HistoryAction) -> None:
+ """Recompute an n-to-1 action in place."""
+ return hrec.recompute_n_to_1_in_place(self, action)
+
+ def recompute_2_to_1_in_place(self, action: HistoryAction) -> None:
+ """Recompute a 2-to-1 action in place."""
+ return hrec.recompute_2_to_1_in_place(self, action)
+
+ def recompute_1_to_0_in_place(self, action: HistoryAction) -> None:
+ """Recompute a 1-to-0 analysis on each source object in place."""
+ return hrec.recompute_1_to_0_in_place(self, action)
+
+ def recompute_cascade(
+ self,
+ root_action: HistoryAction,
+ descendants: list[HistoryAction] | None = None,
+ ) -> None:
+ """Recompute ``root_action``'s descendants in the current session."""
+ return hrec.recompute_cascade(self, root_action, descendants)
+
+ def flush_cascade_warnings(self) -> None:
+ """Show + clear accumulated cascade warnings (no-op when empty)."""
+ return hrec.flush_cascade_warnings(self)
+
+ # ------------------------------------------------------------------
+ # Sync History tree selection → Signal/Image panel
+ # ------------------------------------------------------------------
+
+ def sync_panel_selection(self) -> None:
+ """Sync data panel selection from the currently selected tree item."""
+ if self._replaying or self._syncing:
+ return
+ item = self.tree.currentItem()
+ if item is None or not item.isSelected():
+ return
+ if item.parent() is None:
+ index = self.tree.indexOfTopLevelItem(item)
+ if index < 0 or index >= len(self.history_sessions):
+ return
+ session = self.history_sessions[index]
+ action = next(
+ (a for a in session.actions if a.kind == HistoryAction.KIND_COMPUTE),
+ None,
+ )
+ else:
+ uuid = item.data(0, QC.Qt.UserRole)
+ try:
+ action = self.tree.get_action_from_uuid(uuid, self.history_sessions)
+ except ValueError:
+ action = None
+ if action is None:
+ return
+
+ panel = self.resolve_panel_for_action(action)
+ if panel is None:
+ return
+
+ target_uuids: list[str] = []
+ output_uuid = self.find_output_object_uuid(panel, action)
+ if output_uuid is not None:
+ target_uuids = [output_uuid]
+ else:
+ target_uuids = self.existing_input_uuids(panel, action)
+
+ if not target_uuids:
+ return
+
+ self._syncing = True
+ try:
+ with QC.QSignalBlocker(panel.objview):
+ panel.objview.select_objects(target_uuids)
+ self.mainwindow.set_current_panel(panel)
+ finally:
+ self._syncing = False
+
+ # ------------------------------------------------------------------
+ # Step-by-step navigation
+ # ------------------------------------------------------------------
+
+ def current_action(self) -> HistoryAction | None:
+ """Return the action currently selected in the tree, or ``None``."""
+ item = self.tree.currentItem()
+ if item is None or item.parent() is None:
+ return None
+ uuid = item.data(0, QC.Qt.UserRole)
+ try:
+ return self.tree.get_action_from_uuid(uuid, self.history_sessions)
+ except ValueError:
+ return None
+
+ def current_session(self) -> HistorySession | None:
+ """Return the session relevant for step navigation."""
+ item = self.tree.currentItem()
+ if item is not None:
+ if item.parent() is None:
+ index = self.tree.indexOfTopLevelItem(item)
+ if 0 <= index < len(self.history_sessions):
+ return self.history_sessions[index]
+ else:
+ action = self.current_action()
+ if action is not None:
+ return self.find_parent_session(action)
+ if self.history_sessions:
+ return self.history_sessions[-1]
+ return None
+
+ def can_step_prev(self) -> bool:
+ """Return True if a previous action exists in the current session."""
+ session = self.current_session()
+ if session is None or not session.actions:
+ return False
+ action = self.current_action()
+ if action is None or action not in session.actions:
+ return False
+ return session.actions.index(action) > 0
+
+ def can_step_next(self) -> bool:
+ """Return True if a next action exists in the current session."""
+ session = self.current_session()
+ if session is None or not session.actions:
+ return False
+ action = self.current_action()
+ if action is None or action not in session.actions:
+ return True
+ return session.actions.index(action) < len(session.actions) - 1
+
+ def select_action_in_tree(self, action: HistoryAction) -> None:
+ """Select ``action`` in the tree (triggers ``sync_panel_selection``)."""
+ for i in range(self.tree.topLevelItemCount()):
+ sess_item = self.tree.topLevelItem(i)
+ for j in range(sess_item.childCount()):
+ child = sess_item.child(j)
+ if child.data(0, QC.Qt.UserRole) == action.uuid:
+ self.tree.clearSelection()
+ self.tree.setCurrentItem(child)
+ child.setSelected(True)
+ return
+
+ def step_prev(self) -> None:
+ """Select the previous action in the current session."""
+ if not self.can_step_prev():
+ return
+ session = self.current_session()
+ action = self.current_action()
+ idx = session.actions.index(action)
+ self.select_action_in_tree(session.actions[idx - 1])
+ self.update_actions_state()
+
+ def step_next(self) -> None:
+ """Select the next action in the current session."""
+ if not self.can_step_next():
+ return
+ session = self.current_session()
+ action = self.current_action()
+ if action is None or action not in session.actions:
+ target = session.actions[0]
+ else:
+ target = session.actions[session.actions.index(action) + 1]
+ self.select_action_in_tree(target)
+ self.update_actions_state()
+
+ # ------------------------------------------------------------------
+ # History tools delegations
+ # ------------------------------------------------------------------
+
+ def duplicate_selected_entries(self) -> None:
+ """Duplicate selected entries."""
+ return htools.duplicate_selected_entries(self)
+
+ def generate_macro(self) -> None:
+ """Generate a Python macro script from history."""
+ return htools.generate_macro(self)
+
+ def select_sessions(self, sessions: list[HistorySession]) -> None:
+ """Select top-level tree items matching ``sessions``."""
+ self.tree.clearSelection()
+ for session in sessions:
+ index = self.history_sessions.index(session)
+ item = self.tree.topLevelItem(index)
+ item.setSelected(True)
+ self.tree.setCurrentItem(item)
+
+ def delete_selected(self) -> None:
+ """Delete the currently selected entries."""
+ return htools.delete_selected(self)
+
+ def remove_incompatible_actions(self) -> None:
+ """Remove actions incompatible with the current workspace."""
+ return htools.remove_incompatible_actions(self)
+
+ # ------------------------------------------------------------------
+ # HDF5 / .dlhist I/O delegations
+ # ------------------------------------------------------------------
+
+ def save_to_dlhist_file(self, filename: str | None = None) -> bool:
+ """Save history to a standalone .dlhist file."""
+ return hio.save_to_dlhist_file(self, filename)
+
+ def open_dlhist_file(self, filename: str | None = None) -> bool:
+ """Open history from a standalone .dlhist file."""
+ return hio.open_dlhist_file(self, filename)
+
+ def import_dlhist_into_new_session(self, reader: NativeH5Reader) -> None:
+ """Import a .dlhist into a new session."""
+ return hio.import_dlhist_into_new_session(self, reader)
+
+ def refresh_compatibility_items(self, *args: Any) -> None:
+ """Refresh compatibility icons in the history tree."""
+ return hio.refresh_compatibility_items(self, *args)
+
+ def serialize_to_hdf5(self, writer: NativeH5Writer) -> None:
+ """Serialize the history to HDF5."""
+ return hio.serialize_to_hdf5(self, writer)
+
+ def deserialize_from_hdf5(
+ self, reader: NativeH5Reader, reset_all: bool = False
+ ) -> None:
+ """Deserialize the history from HDF5."""
+ return hio.deserialize_from_hdf5(self, reader, reset_all)
+
+ def __len__(self) -> int:
+ """Return number of objects."""
+ return sum(len(session.actions) for session in self.history_sessions)
+
+ def __getitem__(self, nb: int) -> HistoryAction:
+ """Return object from its number (1 to N)."""
+ for session in self.history_sessions:
+ if nb <= len(session.actions):
+ return session.actions[nb - 1]
+ nb -= len(session.actions)
+ raise IndexError("Index out of range")
+
+ def __iter__(self) -> Generator[HistoryAction, None, None]:
+ """Iterate over objects."""
+ for session in self.history_sessions:
+ yield from session.actions
+
+ # ------------------------------------------------------------------
+ # Session operations delegations
+ # ------------------------------------------------------------------
+
+ def create_new_session(self) -> None:
+ """Create a new history session."""
+ return hsess.create_new_session(self)
+
+ def start_new_session_after_workspace_reset(self) -> None:
+ """Start a new history session after a workspace reset."""
+ return hsess.start_new_session_after_workspace_reset(self)
+
+ def add_compute_entry(
+ self,
+ action_title: str,
+ panel_str: str,
+ func_name: str,
+ pattern: str,
+ save_state: bool = True,
+ output_uuids: list[str] | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> HistoryAction | None:
+ """Add a compute entry to the history."""
+ return hsess.add_compute_entry(
+ self,
+ action_title,
+ panel_str,
+ func_name,
+ pattern,
+ save_state,
+ output_uuids,
+ plugin_origin,
+ **kwargs,
+ )
+
+ def add_compute_entry_from_pp(
+ self,
+ action_title: str,
+ pp: Any,
+ panel_str: str,
+ save_state: bool = True,
+ output_uuids: list[str] | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ **extras: Any,
+ ) -> HistoryAction | None:
+ """Add a compute entry built from a :class:`ProcessingParameters`."""
+ return hsess.add_compute_entry_from_pp(
+ self,
+ action_title,
+ pp,
+ panel_str,
+ save_state,
+ output_uuids,
+ plugin_origin,
+ **extras,
+ )
+
+ def register_action_outputs(
+ self, action: HistoryAction, output_uuids: list[str]
+ ) -> None:
+ """Register the output UUIDs produced by ``action``."""
+ return hsess.register_action_outputs(self, action, output_uuids)
+
+ def capture_outputs(
+ self, action: HistoryAction | None
+ ) -> Generator[None, None, None]:
+ """Context manager capturing outputs produced by ``action``."""
+ return hsess.capture_outputs(self, action)
+
+ def add_ui_entry(
+ self,
+ action_title: str,
+ target: str,
+ method_name: str,
+ save_state: bool = True,
+ **kwargs: Any,
+ ) -> None:
+ """Add a UI entry to the history."""
+ return hsess.add_ui_entry(
+ self, action_title, target, method_name, save_state, **kwargs
+ )
+
+ def add_entry(
+ self,
+ action_title: str,
+ save_state: bool,
+ func: Callable,
+ **kwargs: Any,
+ ) -> None:
+ """Add a generic entry to the history."""
+ return hsess.add_entry(self, action_title, save_state, func, **kwargs)
+
+ # ------ AbstractPanel interface ---------------------------------------------------
+ def create_object(self) -> HistoryAction:
+ """Create and return object."""
+ return HistoryAction()
+
+ def add_object(self, obj: HistoryAction) -> None:
+ """Add an object to the history."""
+ return hsess.add_object(self, obj)
+
+ def remove_all_objects(self) -> None:
+ """Remove all objects."""
+ super().remove_all_objects()
+ self.action_output_uuids.clear()
+ self.output_to_action.clear()
diff --git a/datalab/gui/panel/history/recompute.py b/datalab/gui/panel/history/recompute.py
new file mode 100644
index 00000000..1505d6cb
--- /dev/null
+++ b/datalab/gui/panel/history/recompute.py
@@ -0,0 +1,492 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""In-place recompute helpers for the History panel cascade."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from qtpy import QtWidgets as QW
+from sigima.objects import ImageObj, SignalObj
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.processor.base import (
+ FeatureNotFoundError,
+ ProcessingParameters,
+ extract_processing_parameters,
+ insert_processing_parameters,
+)
+from datalab.history import HistoryAction
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+_logger = logging.getLogger(__name__)
+
+
+def refresh_action(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Refresh the tree display for ``action`` after its kwargs were mutated.
+
+ Used by :meth:`ObjectProp.apply_processing_parameters` to update the
+ Description column when the user edits a ``param`` from the Processing
+ tab of the Signal/Image panel.
+ """
+ panel.tree.refresh_action_item(action)
+
+
+def update_obj_in_place(
+ target_obj: SignalObj | ImageObj,
+ new_obj: SignalObj | ImageObj,
+) -> None:
+ """Copy data + title + metadata from ``new_obj`` onto ``target_obj``.
+
+ Preserves the target's identity (UUID, panel position, references)
+ while reflecting all user-visible changes produced by a recompute.
+ """
+ target_obj.title = new_obj.title
+ if isinstance(target_obj, SignalObj):
+ target_obj.xydata = new_obj.xydata
+ else:
+ target_obj.data = new_obj.data
+ target_obj.invalidate_maskdata_cache()
+ try:
+ saved_uuid = target_obj.metadata.get("__uuid")
+ saved_number = target_obj.metadata.get("__number")
+ target_obj.metadata.clear()
+ target_obj.metadata.update(new_obj.metadata)
+ if saved_uuid is not None:
+ target_obj.metadata["__uuid"] = saved_uuid
+ if saved_number is not None:
+ target_obj.metadata["__number"] = saved_number
+ except AttributeError:
+ pass
+
+
+def refresh_target(panel_data: BaseDataPanel, output_uuid: str) -> None:
+ """Refresh tree item + plot for ``output_uuid`` in ``panel_data``.
+
+ Also updates the Properties panel when the refreshed object is
+ currently selected, marks the object as freshly processed so the
+ Processing tab is shown, and emits ``SIG_OBJECT_MODIFIED``.
+ """
+ panel_data.objview.update_item(output_uuid)
+ panel_data.refresh_plot(output_uuid, update_items=True, force=True)
+ obj = (
+ panel_data.objmodel[output_uuid]
+ if panel_data.objmodel.has_uuid(output_uuid)
+ else None
+ )
+ if obj is not None:
+ if obj is panel_data.objview.get_current_object():
+ panel_data.objprop.update_properties_from(obj, force_tab="processing")
+ else:
+ panel_data.objprop.mark_as_freshly_processed(obj)
+ panel_data.SIG_OBJECT_MODIFIED.emit()
+
+
+def record_missing_outputs(
+ panel: HistoryPanel, action: HistoryAction, missing: list[str]
+) -> None:
+ """Log + queue a user-facing warning for deleted output objects."""
+ if not missing:
+ return
+ name = action.func_name or action.title or action.uuid
+ _logger.warning(
+ "Cascade recompute: %d output(s) missing for action %s (%s).",
+ len(missing),
+ action.uuid,
+ name,
+ )
+ panel.cascade_warnings.append(
+ _(
+ "Action %s has been edited but its target output object(s) "
+ "no longer exist — skipping."
+ )
+ % name
+ )
+
+
+def recompute_action_in_place(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Re-run ``action`` on the existing output object(s) (same UUIDs)."""
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ return
+ method = {
+ "1_to_1": recompute_1_to_1_in_place,
+ "1_to_n": recompute_1_to_n_in_place,
+ "n_to_1": recompute_n_to_1_in_place,
+ "2_to_1": recompute_2_to_1_in_place,
+ "1_to_0": recompute_1_to_0_in_place,
+ }.get(action.pattern or "")
+ if method is None:
+ _logger.warning(
+ "Cascade recompute: unsupported pattern %r for action %s.",
+ action.pattern,
+ action.uuid,
+ )
+ panel.cascade_warnings.append(
+ _("Action %s uses pattern %r which is not recomputable yet.")
+ % (action.func_name or action.uuid, action.pattern)
+ )
+ return
+ try:
+ method(panel, action)
+ except FeatureNotFoundError as exc:
+ handle_missing_feature(panel, action, exc)
+ except (RuntimeError, ValueError, AttributeError, KeyError, TypeError) as exc:
+ _logger.exception(
+ "Cascade recompute failed for action %s (%s): %s",
+ action.uuid,
+ action.func_name,
+ exc,
+ )
+ panel.cascade_warnings.append(
+ _("Recompute failed for action %s: %s")
+ % (action.func_name or action.uuid, exc)
+ )
+
+
+def handle_missing_feature(
+ panel: HistoryPanel, action: HistoryAction, exc: FeatureNotFoundError
+) -> None:
+ """Flag ``action`` as broken (missing plugin) and queue a user warning."""
+ action.is_stale = True
+ panel.broken_actions.add(action.uuid)
+ plugin_origin = action.plugin_origin or exc.plugin_origin or {}
+ directory = (plugin_origin.get("directory") if plugin_origin else None) or "?"
+ param = action.kwargs.get("param")
+ paramclass = exc.paramclass_name or (
+ type(param).__name__ if param is not None else "—"
+ )
+ func_name = action.func_name or exc.func_name or action.uuid
+ location = f"{directory}/plugins:{func_name}"
+ _logger.warning(
+ "Cascade recompute: plugin missing for action %s (%s) — %s.",
+ action.uuid,
+ func_name,
+ location,
+ )
+ panel.cascade_warnings.append(
+ _(
+ "Action %(name)s skipped: plugin '%(loc)s' is missing.\n"
+ "Required parameter class: %(param)s\n"
+ "Reinstall the plugin to re-enable this action."
+ )
+ % {"name": func_name, "loc": location, "param": paramclass}
+ )
+
+
+def recompute_1_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Recompute a single 1-to-1 action in place."""
+ panel_data = panel.resolve_panel_for_action(action)
+ if panel_data is None:
+ return
+ existing, missing = panel.resolve_target_outputs(panel_data, action)
+ if not existing and not missing:
+ legacy = panel.find_output_object_uuid(panel_data, action)
+ if legacy is not None:
+ existing = [legacy]
+ record_missing_outputs(panel, action, missing)
+ if not existing:
+ return
+ output_uuid = existing[0]
+ if not panel_data.objmodel.has_uuid(output_uuid):
+ return
+ output_obj = panel_data.objmodel[output_uuid]
+ pp = extract_processing_parameters(output_obj)
+ if pp is None or pp.source_uuid is None:
+ return
+ if not panel_data.objmodel.has_uuid(pp.source_uuid):
+ panel.cascade_warnings.append(
+ _("Action %s: source object was deleted — skipping.")
+ % (action.func_name or action.uuid)
+ )
+ return
+ source_obj = panel_data.objmodel[pp.source_uuid]
+ param = action.kwargs.get("param")
+ new_obj = panel_data.processor.recompute_1_to_1(
+ action.func_name,
+ source_obj,
+ param,
+ plugin_origin=action.plugin_origin,
+ )
+ if new_obj is None:
+ return
+ update_obj_in_place(output_obj, new_obj)
+ insert_processing_parameters(
+ output_obj,
+ ProcessingParameters(
+ func_name=pp.func_name,
+ pattern=pp.pattern,
+ param=param if param is not None else pp.param,
+ source_uuid=pp.source_uuid,
+ ),
+ )
+ panel_data.processor.auto_recompute_analysis(output_obj)
+ refresh_target(panel_data, output_uuid)
+
+
+def recompute_1_to_n_in_place(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Recompute a 1-to-n action in place: replace each of the N outputs."""
+ panel_data = panel.resolve_panel_for_action(action)
+ if panel_data is None:
+ return
+ existing, missing = panel.resolve_target_outputs(panel_data, action)
+ record_missing_outputs(panel, action, missing)
+ if not existing or not panel_data.objmodel.has_uuid(existing[0]):
+ return
+ first_obj = panel_data.objmodel[existing[0]]
+ pp = extract_processing_parameters(first_obj)
+ if pp is None or pp.source_uuid is None:
+ return
+ if not panel_data.objmodel.has_uuid(pp.source_uuid):
+ panel.cascade_warnings.append(
+ _("Action %s: source object was deleted — skipping.")
+ % (action.func_name or action.uuid)
+ )
+ return
+ source_obj = panel_data.objmodel[pp.source_uuid]
+ params = action.kwargs.get("params") or []
+ if not params:
+ return
+ new_objs = panel_data.processor.recompute_1_to_n(
+ action.func_name,
+ source_obj,
+ params,
+ plugin_origin=action.plugin_origin,
+ )
+ if not new_objs:
+ return
+ n = min(len(existing), len(new_objs))
+ for idx in range(n):
+ out_uuid = existing[idx]
+ if not panel_data.objmodel.has_uuid(out_uuid):
+ continue
+ out_obj = panel_data.objmodel[out_uuid]
+ new_obj = new_objs[idx]
+ update_obj_in_place(out_obj, new_obj)
+ new_param = params[idx] if idx < len(params) else None
+ insert_processing_parameters(
+ out_obj,
+ ProcessingParameters(
+ func_name=action.func_name,
+ pattern="1-to-n",
+ param=new_param,
+ source_uuid=pp.source_uuid,
+ ),
+ )
+ panel_data.processor.auto_recompute_analysis(out_obj)
+ refresh_target(panel_data, out_uuid)
+ if len(new_objs) != len(existing):
+ _logger.warning(
+ "1-to-n cardinality changed for action %s: %d outputs, %d existing.",
+ action.uuid,
+ len(new_objs),
+ len(existing),
+ )
+
+
+def recompute_n_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Recompute an n-to-1 action in place."""
+ panel_data = panel.resolve_panel_for_action(action)
+ if panel_data is None:
+ return
+ existing, missing = panel.resolve_target_outputs(panel_data, action)
+ record_missing_outputs(panel, action, missing)
+ if not existing:
+ return
+ output_uuid = existing[0]
+ if not panel_data.objmodel.has_uuid(output_uuid):
+ return
+ output_obj = panel_data.objmodel[output_uuid]
+ pp = extract_processing_parameters(output_obj)
+ source_uuids: list[str] = []
+ if pp is not None and pp.source_uuids:
+ source_uuids = list(pp.source_uuids)
+ else:
+ source_uuids = list(action.state.selection.get(panel_data.PANEL_STR_ID, []))
+ src_objs: list[SignalObj | ImageObj] = []
+ for uuid in source_uuids:
+ if panel_data.objmodel.has_uuid(uuid):
+ src_objs.append(panel_data.objmodel[uuid])
+ if not src_objs:
+ panel.cascade_warnings.append(
+ _("Action %s: all source objects were deleted — skipping.")
+ % (action.func_name or action.uuid)
+ )
+ return
+ param = action.kwargs.get("param")
+ new_obj = panel_data.processor.recompute_n_to_1(
+ action.func_name,
+ src_objs,
+ param,
+ plugin_origin=action.plugin_origin,
+ )
+ if new_obj is None:
+ return
+ update_obj_in_place(output_obj, new_obj)
+ insert_processing_parameters(
+ output_obj,
+ ProcessingParameters(
+ func_name=action.func_name,
+ pattern="n-to-1",
+ param=param,
+ source_uuids=[get_uuid(o) for o in src_objs],
+ ),
+ )
+ panel_data.processor.auto_recompute_analysis(output_obj)
+ refresh_target(panel_data, output_uuid)
+
+
+def recompute_2_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Recompute a 2-to-1 action in place (single or pairwise)."""
+ panel_data = panel.resolve_panel_for_action(action)
+ if panel_data is None:
+ return
+ existing, missing = panel.resolve_target_outputs(panel_data, action)
+ record_missing_outputs(panel, action, missing)
+ if not existing:
+ return
+ param = action.kwargs.get("param")
+ obj2_uuids = action.kwargs.get("obj2_uuids") or []
+ if isinstance(obj2_uuids, str):
+ obj2_uuids = [obj2_uuids]
+ pairwise = bool(action.kwargs.get("pairwise"))
+ recorded_inputs = list(action.state.selection.get(panel_data.PANEL_STR_ID, []))
+ for idx, out_uuid in enumerate(existing):
+ if not panel_data.objmodel.has_uuid(out_uuid):
+ continue
+ output_obj = panel_data.objmodel[out_uuid]
+ pp = extract_processing_parameters(output_obj)
+ src_uuids = (
+ list(pp.source_uuids)
+ if pp is not None and pp.source_uuids
+ else (
+ recorded_inputs[idx : idx + 1] + obj2_uuids[idx : idx + 1]
+ if pairwise
+ else recorded_inputs[idx : idx + 1] + obj2_uuids[:1]
+ )
+ )
+ if len(src_uuids) < 2:
+ panel.cascade_warnings.append(
+ _("Action %s: missing source(s) for output #%d — skipping.")
+ % (action.func_name or action.uuid, idx + 1)
+ )
+ continue
+ if not (
+ panel_data.objmodel.has_uuid(src_uuids[0])
+ and panel_data.objmodel.has_uuid(src_uuids[1])
+ ):
+ panel.cascade_warnings.append(
+ _("Action %s: source object(s) were deleted — skipping.")
+ % (action.func_name or action.uuid)
+ )
+ continue
+ obj1 = panel_data.objmodel[src_uuids[0]]
+ obj2 = panel_data.objmodel[src_uuids[1]]
+ new_obj = panel_data.processor.recompute_2_to_1(
+ action.func_name,
+ obj1,
+ obj2,
+ param,
+ plugin_origin=action.plugin_origin,
+ )
+ if new_obj is None:
+ continue
+ update_obj_in_place(output_obj, new_obj)
+ insert_processing_parameters(
+ output_obj,
+ ProcessingParameters(
+ func_name=action.func_name,
+ pattern="2-to-1",
+ param=param,
+ source_uuids=[get_uuid(obj1), get_uuid(obj2)],
+ ),
+ )
+ panel_data.processor.auto_recompute_analysis(output_obj)
+ refresh_target(panel_data, out_uuid)
+
+
+def recompute_1_to_0_in_place(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Recompute a 1-to-0 analysis on each source object in place."""
+ panel_data = panel.resolve_panel_for_action(action)
+ if panel_data is None:
+ return
+ sources = list(action.state.selection.get(panel_data.PANEL_STR_ID, []))
+ if not sources:
+ return
+ param = action.kwargs.get("param")
+ missing: list[str] = []
+ for uuid in sources:
+ if not panel_data.objmodel.has_uuid(uuid):
+ missing.append(uuid)
+ continue
+ src_obj = panel_data.objmodel[uuid]
+ panel_data.processor.recompute_1_to_0(
+ action.func_name,
+ src_obj,
+ param,
+ plugin_origin=action.plugin_origin,
+ )
+ refresh_target(panel_data, uuid)
+ if missing:
+ panel.cascade_warnings.append(
+ _("Action %s: %d analysed object(s) were deleted — skipping.")
+ % (action.func_name or action.uuid, len(missing))
+ )
+
+
+def recompute_cascade(
+ panel: HistoryPanel,
+ root_action: HistoryAction,
+ descendants: list[HistoryAction] | None = None,
+) -> None:
+ """Recompute ``root_action``'s descendants in the current session in place."""
+ if descendants is None:
+ descendants = panel.get_downstream_actions(root_action)
+ if root_action.is_stale:
+ descendants = [root_action] + descendants
+ if getattr(panel, "cascade_in_progress", False):
+ flush_cascade_warnings(panel)
+ return
+ if not descendants:
+ flush_cascade_warnings(panel)
+ return
+ panel.broken_actions.clear()
+ panel.cascade_in_progress = True
+ try:
+ for action in descendants:
+ action.is_stale = True
+ panel.tree.refresh_action_item(action)
+ QW.QApplication.processEvents()
+ for action in descendants:
+ try:
+ recompute_action_in_place(panel, action)
+ finally:
+ if action.uuid not in panel.broken_actions:
+ action.is_stale = False
+ panel.tree.refresh_action_item(action)
+ QW.QApplication.processEvents()
+ finally:
+ for action in descendants:
+ if action.is_stale and action.uuid not in panel.broken_actions:
+ action.is_stale = False
+ panel.tree.refresh_action_item(action)
+ panel.cascade_in_progress = False
+ flush_cascade_warnings(panel)
+
+
+def flush_cascade_warnings(panel: HistoryPanel) -> None:
+ """Show + clear accumulated cascade warnings (no-op when empty)."""
+ if panel.cascade_warnings and not execenv.unattended:
+ QW.QMessageBox.warning(
+ panel.mainwindow,
+ _("Cascade recompute"),
+ _("Some downstream actions could not be recomputed:")
+ + "\n\n• "
+ + "\n• ".join(panel.cascade_warnings),
+ )
+ panel.cascade_warnings = []
diff --git a/datalab/gui/panel/image.py b/datalab/gui/panel/image.py
index 661c752e..75c727fe 100644
--- a/datalab/gui/panel/image.py
+++ b/datalab/gui/panel/image.py
@@ -255,6 +255,14 @@ def new_object(
image = create_image_gui(param, edit=edit, parent=self.parentWidget())
if image is None:
return None
+ self.mainwindow.historypanel.add_ui_entry(
+ _("New image"),
+ target="imagepanel",
+ method_name="new_object",
+ save_state=False,
+ param=param,
+ add_to_panel=add_to_panel,
+ )
if add_to_panel:
self.add_object(image)
return image
diff --git a/datalab/gui/panel/signal.py b/datalab/gui/panel/signal.py
index 26d8abca..d147f363 100644
--- a/datalab/gui/panel/signal.py
+++ b/datalab/gui/panel/signal.py
@@ -146,6 +146,14 @@ def new_object(
signal = create_signal_gui(param, edit=edit, parent=self.parentWidget())
if signal is None:
return None
+ self.mainwindow.historypanel.add_ui_entry(
+ _("New signal"),
+ target="signalpanel",
+ method_name="new_object",
+ save_state=False,
+ param=param,
+ add_to_panel=add_to_panel,
+ )
if add_to_panel:
self.add_object(signal)
return signal
diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py
index fd421fed..dec384ff 100644
--- a/datalab/gui/processor/base.py
+++ b/datalab/gui/processor/base.py
@@ -9,10 +9,12 @@
from __future__ import annotations
import abc
+import inspect
import multiprocessing
+import os.path as osp
import time
import warnings
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
from enum import Enum, auto
from multiprocessing.pool import Pool
from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Optional
@@ -195,6 +197,43 @@ def insert_processing_parameters(
obj.set_metadata_option(PROCESSING_PARAMETERS_OPTION, pp.to_dict())
+def build_processing_parameters(
+ func_name: str,
+ pattern: str,
+ *,
+ param: gds.DataSet | list[gds.DataSet] | None = None,
+ source_uuid: str | None = None,
+ source_uuids: list[str] | None = None,
+) -> ProcessingParameters:
+ """Single factory for :class:`ProcessingParameters`.
+
+ Centralises construction so that history-panel entries and per-object
+ metadata always share the same identity (``func_name``, ``pattern``,
+ ``param``).
+
+ Args:
+ func_name: Sigima feature name.
+ pattern: Dash-form pattern (``"1-to-1"``, ``"1-to-0"``,
+ ``"n-to-1"``, ``"2-to-1"``, ``"1-to-n"``).
+ param: Optional parameter dataset (or list of datasets for
+ multi-parameter patterns).
+ source_uuid: Source object UUID for ``"1-to-1"`` / ``"1-to-0"`` /
+ ``"1-to-n"`` patterns.
+ source_uuids: Source object UUIDs for ``"n-to-1"`` / ``"2-to-1"``
+ patterns.
+
+ Returns:
+ Newly constructed :class:`ProcessingParameters`.
+ """
+ return ProcessingParameters(
+ func_name=func_name,
+ pattern=pattern,
+ param=param,
+ source_uuid=source_uuid,
+ source_uuids=source_uuids,
+ )
+
+
def clear_analysis_parameters(obj: SignalObj | ImageObj) -> None:
"""Clear analysis parameters from object metadata.
@@ -490,6 +529,158 @@ def is_pairwise_mode() -> bool:
return state
+class FeatureNotFoundError(ValueError):
+ """Raised when a computing feature cannot be resolved by name or callable.
+
+ Inherits from :class:`ValueError` to preserve backward compatibility with
+ callers that already catch ``ValueError`` on lookup failures.
+
+ Attributes:
+ func_name: Name (or repr) of the missing feature.
+ plugin_origin: Optional plugin origin descriptor captured at registration
+ time. See :func:`_detect_plugin_origin` for the dict shape.
+ paramclass_name: Optional name of the required parameter class (for
+ diagnostic display).
+ """
+
+ def __init__(
+ self,
+ func_name: str,
+ plugin_origin: dict[str, Any] | None = None,
+ paramclass_name: str | None = None,
+ ) -> None:
+ self.func_name = func_name
+ self.plugin_origin = plugin_origin
+ self.paramclass_name = paramclass_name
+ super().__init__(self._build_message())
+
+ def _build_message(self) -> str:
+ """Build the default exception message."""
+ if self.plugin_origin:
+ po = self.plugin_origin
+ param = self.paramclass_name or "—"
+ return (
+ f"Cannot replay action: function '{self.func_name}' from plugin "
+ f"'{po.get('plugin_class')}' (module: {po.get('module')}, "
+ f"directory: {po.get('directory')}) is not available. "
+ f"Required parameter class: {param}. "
+ "Please reinstall or check the plugin."
+ )
+ return f"Unknown computing feature: {self.func_name}"
+
+
+# Module name prefixes considered as built-in (not plugin) origins.
+_BUILTIN_MODULE_PREFIXES: tuple[str, ...] = (
+ "sigima",
+ "datalab",
+ "numpy",
+ "scipy",
+ "skimage",
+ "guidata",
+ "plotpy",
+ "qtpy",
+ "builtins",
+ "__main__",
+)
+
+
+def _detect_plugin_origin(func: Callable) -> dict[str, Any] | None:
+ """Detect whether ``func`` originates from a DataLab plugin.
+
+ Inspects ``func.__module__`` and compares it against registered plugins
+ (:class:`datalab.plugins.PluginRegistry`). Falls back to a heuristic for
+ modules that are clearly not from the DataLab/Sigima/scientific-Python
+ built-in surface (then treated as "anonymous" plugin origin).
+
+ **Wrapper-aware**: when *func* is a Sigima wrapper (e.g.
+ ``Wrap1to1Func``), its ``__module__`` points to the wrapper class's
+ module (``sigima.proc.image.base``), not to the user-supplied function.
+ The method therefore probes ``func.__wrapped__`` (``functools.wraps``
+ convention) and ``func.func`` (Sigima ``Wrap1to1Func`` / signal
+ ``Wrap1to1Func`` attribute) to recover the *inner* function and uses
+ that function's ``__module__`` for origin detection.
+
+ Args:
+ func: Computation function to inspect.
+
+ Returns:
+ A dict ``{"plugin_class", "module", "directory", "version"}`` if the
+ function originates from a plugin, otherwise ``None``.
+ """
+ # Build a list of candidate functions to inspect, starting with the
+ # innermost wrapped function so that plugin origins are detected even
+ # when the outer callable belongs to a built-in module (e.g. sigima).
+ candidates: list[Callable] = []
+ inner = getattr(func, "__wrapped__", None) or getattr(func, "func", None)
+ if inner is not None and callable(inner):
+ candidates.append(inner)
+ candidates.append(func)
+
+ module_name = ""
+ for candidate in candidates:
+ mod = getattr(candidate, "__module__", "") or ""
+ if mod:
+ top = mod.split(".", 1)[0]
+ if top not in _BUILTIN_MODULE_PREFIXES:
+ module_name = mod
+ break
+ if not module_name:
+ # All candidates are built-in; fall back to the outer func's module
+ # so the rest of the logic can still run (and return None).
+ module_name = getattr(func, "__module__", "") or ""
+ if not module_name:
+ return None
+ # Local import to avoid a circular dependency at module load time.
+ try:
+ from datalab.plugins import ( # pylint: disable=import-outside-toplevel
+ PluginRegistry,
+ )
+ except ImportError:
+ PluginRegistry = None # type: ignore[assignment]
+
+ if PluginRegistry is not None:
+ for plugin in PluginRegistry.get_plugins():
+ plugin_module = plugin.__class__.__module__
+ if module_name == plugin_module or module_name.startswith(
+ plugin_module + "."
+ ):
+ directory: str | None = None
+ try:
+ directory = osp.basename(
+ osp.dirname(inspect.getfile(plugin.__class__))
+ )
+ except (TypeError, OSError):
+ pass
+ version: str | None = None
+ info = getattr(plugin, "info", None)
+ if info is not None:
+ version = getattr(info, "version", None)
+ return {
+ "plugin_class": plugin.__class__.__name__,
+ "module": module_name,
+ "directory": directory,
+ "version": version,
+ }
+
+ # Heuristic fallback: anything not from a known built-in prefix is
+ # treated as an anonymous plugin origin (e.g. user macros, third-party
+ # functions wrapped through ``compute_1_to_1`` directly).
+ top = module_name.split(".", 1)[0]
+ if top and top not in _BUILTIN_MODULE_PREFIXES:
+ directory = None
+ try:
+ directory = osp.basename(osp.dirname(inspect.getfile(func)))
+ except (TypeError, OSError):
+ pass
+ return {
+ "plugin_class": None,
+ "module": module_name,
+ "directory": directory,
+ "version": None,
+ }
+ return None
+
+
@dataclass
class ComputingFeature:
"""Computing feature dataclass.
@@ -504,6 +695,9 @@ class ComputingFeature:
edit: whether to edit the parameters
obj2_name: name of the second object
skip_xarray_compat: whether to skip X-array compatibility check for this feature
+ plugin_origin: optional plugin origin descriptor (auto-detected at
+ :meth:`BaseProcessor.add_feature` time). ``None`` for built-in
+ (Sigima/DataLab) features.
"""
pattern: Literal["1_to_1", "1_to_0", "1_to_n", "n_to_1", "2_to_1"]
@@ -515,6 +709,7 @@ class ComputingFeature:
edit: Optional[bool] = None
obj2_name: Optional[str] = None
skip_xarray_compat: Optional[bool] = None
+ plugin_origin: Optional[dict[str, Any]] = field(default=None)
def __post_init__(self):
"""Validate the function after initialization."""
@@ -739,6 +934,9 @@ def _add_object_to_appropriate_panel(
If False, non-native objects are added to default group. Set to False when
group_id is from the source panel and object goes to a different panel.
"""
+ hpanel = getattr(self.mainwindow, "historypanel", None)
+ if hpanel is not None and hpanel.is_output_suppressed():
+ return
is_new_obj_native = isinstance(new_obj, self.panel.PARAMCLASS)
if is_new_obj_native:
self.panel.add_object(new_obj, group_id=group_id)
@@ -750,7 +948,7 @@ def _add_object_to_appropriate_panel(
def _create_group_for_result(
self, new_obj: SignalObj | ImageObj, group_name: str
- ) -> str:
+ ) -> str | None:
"""Create a group in the appropriate panel for the result object.
For native objects, creates group in current panel. For non-native objects,
@@ -761,8 +959,11 @@ def _create_group_for_result(
group_name: Name for the new group
Returns:
- UUID of the created group
+ UUID of the created group.
"""
+ hpanel = getattr(self.mainwindow, "historypanel", None)
+ if hpanel is not None and hpanel.is_output_suppressed():
+ return None
is_new_obj_native = isinstance(new_obj, self.panel.PARAMCLASS)
if is_new_obj_native:
return get_uuid(self.panel.add_group(group_name))
@@ -1024,8 +1225,12 @@ def auto_recompute_analysis(
feature = self.get_feature(proc_params.func_name)
# Recompute the analysis operation silently, only for this specific object
- # (not all selected objects, to avoid O(n²) behavior when called in a loop)
- with Conf.proc.show_result_dialog.temp(False):
+ # (not all selected objects, to avoid O(n²) behavior when called in a loop).
+ # The history-panel ``replaying()`` guard suppresses the synthetic entry
+ # that ``compute_1_to_0`` would otherwise add for this internally-triggered
+ # recomputation.
+ historypanel = self.panel.mainwindow.historypanel
+ with historypanel.replaying(), Conf.proc.show_result_dialog.temp(False):
self.compute_1_to_0(feature.function, param, edit=False, target_objs=[obj])
# Update the view
@@ -1073,6 +1278,7 @@ def recompute_1_to_1(
func_name: str,
obj: SignalObj | ImageObj,
param: gds.DataSet | None = None,
+ plugin_origin: dict[str, Any] | None = None,
) -> SignalObj | ImageObj | None:
"""Recompute a 1-to-1 processing operation without adding result to panel.
@@ -1085,18 +1291,22 @@ def recompute_1_to_1(
func_name: Name of the processing function
obj: Source object to process
param: Processing parameters (optional)
+ plugin_origin: Optional plugin origin descriptor (propagated to
+ :meth:`get_feature` for richer error reporting).
Returns:
New processed object (not added to panel), or None if cancelled or error
Raises:
- ValueError: If function is not found in registry
+ FeatureNotFoundError: If function is not found in registry.
"""
# Get the function from the registry
- try:
- feature = self.get_feature(func_name)
- except ValueError as exc:
- raise ValueError(f"Function '{func_name}' not found in registry") from exc
+ paramclass_name = type(param).__name__ if param is not None else None
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
func = feature.function
@@ -1125,6 +1335,183 @@ def recompute_1_to_1(
patch_title_with_ids(new_obj, [obj], get_short_id)
return new_obj
+ # ------------------------------------------------------------------
+ # In-place recompute helpers used by the History panel cascade
+ # (Edit mode tweaks + downstream propagation). They mirror their
+ # ``compute_*`` counterparts but:
+ # - never add results to a panel (caller updates targets in place);
+ # - never record a history entry (cascade runs under ``replaying``);
+ # - never insert :class:`ProcessingParameters` (caller does, so that
+ # ``source_uuid`` / ``source_uuids`` stay consistent with the
+ # existing output object identity).
+ # ------------------------------------------------------------------
+
+ def recompute_1_to_n(
+ self,
+ func_name: str,
+ obj: SignalObj | ImageObj,
+ params: list[gds.DataSet],
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> list[SignalObj | ImageObj] | None:
+ """Recompute a 1-to-n processing operation without adding results to panel.
+
+ Args:
+ func_name: Name of the processing function.
+ obj: Source object to process.
+ params: List of N parameter datasets (one per output).
+ plugin_origin: Optional plugin origin descriptor.
+
+ Returns:
+ List of N new objects (in input order), or ``None`` if cancelled
+ or an unrecoverable error occurred. Shorter lists are possible
+ when individual sub-calls return ``None``.
+ """
+ paramclass_name = (
+ type(params[0]).__name__ if params and params[0] is not None else None
+ )
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
+ func = feature.function
+ results: list[SignalObj | ImageObj] = []
+ with create_progress_bar(
+ self.panel, _("Recomputing..."), max_=len(params)
+ ) as progress:
+ for idx, param in enumerate(params):
+ progress.setValue(idx)
+ progress.setLabelText(_("Processing object with updated parameters..."))
+ args = (obj, param) if param is not None else (obj,)
+ comp_out = self.__exec_func(func, args, progress)
+ if comp_out is None:
+ return None
+ new_obj = self.handle_output(comp_out, _("Recomputing"), progress)
+ if new_obj is None:
+ continue
+ if isinstance(new_obj, (SignalObj, ImageObj)):
+ self._handle_keep_results(new_obj)
+ patch_title_with_ids(new_obj, [obj], get_short_id)
+ results.append(new_obj)
+ return results
+
+ def recompute_n_to_1(
+ self,
+ func_name: str,
+ objs: list[SignalObj | ImageObj],
+ param: gds.DataSet | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> SignalObj | ImageObj | None:
+ """Recompute an n-to-1 processing operation without adding result to panel.
+
+ Args:
+ func_name: Name of the processing function.
+ objs: Source object list to aggregate.
+ param: Processing parameters (optional).
+ plugin_origin: Optional plugin origin descriptor.
+
+ Returns:
+ New aggregated object, or ``None`` if cancelled / errored.
+
+ .. note::
+ Pairwise mode is not handled here: each pairwise output is a
+ distinct single-output recompute -- the caller is expected to
+ split the work per output and iterate.
+ """
+ paramclass_name = type(param).__name__ if param is not None else None
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
+ func = feature.function
+ with create_progress_bar(self.panel, _("Recomputing..."), max_=1) as progress:
+ progress.setValue(0)
+ progress.setLabelText(_("Processing object with updated parameters..."))
+ args = (objs, param) if param is not None else (objs,)
+ comp_out = self.__exec_func(func, args, progress)
+ if comp_out is None:
+ return None
+ new_obj = self.handle_output(comp_out, _("Recomputing"), progress)
+ if new_obj is None:
+ return None
+ if isinstance(new_obj, (SignalObj, ImageObj)):
+ self._handle_keep_results(new_obj)
+ self._merge_geometry_results_for_n_to_1(new_obj, objs)
+ patch_title_with_ids(new_obj, objs, get_short_id)
+ return new_obj
+
+ def recompute_2_to_1(
+ self,
+ func_name: str,
+ obj1: SignalObj | ImageObj,
+ obj2: SignalObj | ImageObj,
+ param: gds.DataSet | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> SignalObj | ImageObj | None:
+ """Recompute a 2-to-1 processing operation without adding result to panel.
+
+ Args:
+ func_name: Name of the processing function.
+ obj1: First source object.
+ obj2: Second source object.
+ param: Processing parameters (optional).
+ plugin_origin: Optional plugin origin descriptor.
+
+ Returns:
+ New combined object, or ``None`` if cancelled / errored.
+ """
+ paramclass_name = type(param).__name__ if param is not None else None
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
+ func = feature.function
+ with create_progress_bar(self.panel, _("Recomputing..."), max_=1) as progress:
+ progress.setValue(0)
+ progress.setLabelText(_("Processing object with updated parameters..."))
+ args = (obj1, obj2, param) if param is not None else (obj1, obj2)
+ comp_out = self.__exec_func(func, args, progress)
+ if comp_out is None:
+ return None
+ new_obj = self.handle_output(comp_out, _("Recomputing"), progress)
+ if new_obj is None:
+ return None
+ if isinstance(new_obj, (SignalObj, ImageObj)):
+ self._handle_keep_results(new_obj)
+ patch_title_with_ids(new_obj, [obj1, obj2], get_short_id)
+ return new_obj
+
+ def recompute_1_to_0(
+ self,
+ func_name: str,
+ obj: SignalObj | ImageObj,
+ param: gds.DataSet | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> None:
+ """Recompute a 1-to-0 analysis on ``obj`` in place.
+
+ Reuses :meth:`compute_1_to_0` with ``target_objs=[obj]`` under the
+ history-panel ``replaying`` guard so no synthetic history entry is
+ recorded. The analysis result is written to ``obj``'s metadata.
+
+ Args:
+ func_name: Name of the analysis function.
+ obj: Object whose analysis must be refreshed.
+ param: Analysis parameters (optional).
+ plugin_origin: Optional plugin origin descriptor.
+ """
+ paramclass_name = type(param).__name__ if param is not None else None
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
+ historypanel = self.mainwindow.historypanel
+ with historypanel.replaying(), Conf.proc.show_result_dialog.temp(False):
+ self.compute_1_to_0(feature.function, param, edit=False, target_objs=[obj])
+
def _compute_1_to_1_subroutine(
self, funcs: list[Callable], params: list, title: str
) -> None:
@@ -1269,7 +1656,7 @@ def compute_1_to_1(
comment: str | None = None,
edit: bool | None = None,
) -> None:
- """Generic processing method: 1 object in → 1 object out.
+ """Generic processing method: 1 object in → 1 object out.
Applies a function independently to each selected object in the active panel.
The result of each computation is a new object appended to the same panel.
@@ -1299,7 +1686,15 @@ def compute_1_to_1(
if param is not None:
if edit and not param.edit(parent=self.mainwindow):
return
- self._compute_1_to_1_subroutine([func], [param], title)
+ pp = build_processing_parameters(func.__name__, "1-to-1", param=param)
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or func.__name__,
+ pp,
+ panel_str=self.panel.PANEL_STR_ID,
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+ with self.mainwindow.historypanel.capture_outputs(action):
+ self._compute_1_to_1_subroutine([func], [param], title)
def compute_multiple_1_to_1(
self,
@@ -1308,7 +1703,7 @@ def compute_multiple_1_to_1(
title: str | None = None,
edit: bool | None = None,
) -> None:
- """Generic processing method: 1 object in → n objects out.
+ """Generic processing method: 1 object in → n objects out.
Applies multiple functions to each selected object, generating multiple
outputs per object. The resulting objects are appended to the active panel.
@@ -1324,7 +1719,7 @@ def compute_multiple_1_to_1(
.. note::
With k selected objects and n outputs per function,
- the method produces k × n outputs.
+ the method produces k × n outputs.
.. note::
This method does not support pairwise mode.
@@ -1337,7 +1732,19 @@ def compute_multiple_1_to_1(
return
if len(funcs) != len(params):
raise ValueError("Number of functions must match number of parameters")
- self._compute_1_to_1_subroutine(funcs, params, title)
+ pp = build_processing_parameters(
+ funcs[0].__name__ if funcs else "", "multiple-1-to-1"
+ )
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or "compute_multiple_1_to_1",
+ pp,
+ panel_str=self.panel.PANEL_STR_ID,
+ func_names=[f.__name__ for f in funcs],
+ params=params if any(p is not None for p in params) else None,
+ plugin_origin=(self._get_plugin_origin_for(funcs[0]) if funcs else None),
+ )
+ with self.mainwindow.historypanel.capture_outputs(action):
+ self._compute_1_to_1_subroutine(funcs, params, title)
def compute_1_to_n(
self,
@@ -1346,7 +1753,7 @@ def compute_1_to_n(
title: str | None = None,
edit: bool | None = None,
) -> None:
- """Generic processing method: 1 object in → n objects out.
+ """Generic processing method: 1 object in → n objects out.
Applies a single function to each selected object, with n different parameters
set, thus generating n outputs per object. The resulting objects are appended to
@@ -1363,7 +1770,7 @@ def compute_1_to_n(
.. note::
With k selected objects and n parameter sets,
- the method produces k × n outputs.
+ the method produces k × n outputs.
.. note::
This method does not support pairwise mode.
@@ -1373,7 +1780,16 @@ def compute_1_to_n(
group = gds.DataSetGroup(params, title=_("Parameters"))
if not group.edit(parent=self.mainwindow):
return
- self._compute_1_to_1_subroutine([func] * len(params), params, title)
+ pp = build_processing_parameters(func.__name__, "1-to-n")
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or func.__name__,
+ pp,
+ panel_str=self.panel.PANEL_STR_ID,
+ params=params,
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+ with self.mainwindow.historypanel.capture_outputs(action):
+ self._compute_1_to_1_subroutine([func] * len(params), params, title)
def compute_1_to_0(
self,
@@ -1385,7 +1801,7 @@ def compute_1_to_0(
edit: bool | None = None,
target_objs: list[SignalObj | ImageObj] | None = None,
) -> ResultData:
- """Generic processing method: 1 object in → no object out.
+ """Generic processing method: 1 object in → no object out.
Applies a function to each selected object (or specified target objects),
returning metadata or measurement results (e.g. peak coordinates, statistical
@@ -1429,6 +1845,17 @@ def compute_1_to_0(
return None
current_obj = self.panel.objview.get_current_object()
title = func.__name__ if title is None else title
+ pp_history = build_processing_parameters(func.__name__, "1-to-0", param=param)
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title,
+ pp_history,
+ panel_str=self.panel.PANEL_STR_ID,
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+ # 1-to-0: no data object is produced. Register an empty output list so
+ # the bijective mapping records the action even with zero outputs.
+ if action is not None:
+ self.mainwindow.historypanel.register_action_outputs(action, [])
refresh_needed = False
with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
rdata = ResultData()
@@ -1504,8 +1931,9 @@ def compute_n_to_1(
title: str | None = None,
comment: str | None = None,
edit: bool | None = None,
+ pairwise: bool | None = None,
) -> None:
- """Generic processing method: n objects in → 1 object out.
+ """Generic processing method: n objects in → 1 object out.
Aggregates multiple selected objects into a single result using the provided
function. In pairwise mode, applies the function to object pairs (grouped by
@@ -1536,202 +1964,224 @@ def compute_n_to_1(
objs = self.panel.objview.get_sel_objects(include_groups=True)
objmodel = self.panel.objmodel
- pairwise = is_pairwise_mode()
+ pairwise = is_pairwise_mode() if pairwise is None else pairwise
name = func.__name__
- if pairwise:
- src_grps, src_gids, src_objs, _nbobj, valid = (
- self.__get_src_grps_gids_objs_nbobj_valid(min_group_nb=2)
- )
- if not valid:
- return
- dst_gname = (
- f"{name}({','.join([get_short_id(grp) for grp in src_grps])})|pairwise"
- )
- group_exclusive = len(self.panel.objview.get_sel_groups()) != 0
- if not group_exclusive:
- # This is not a group exclusive selection
- dst_gname += "[...]"
- # Delay group creation until after first result to determine target panel
- dst_gid = None
- n_pairs = len(src_objs[src_gids[0]])
- max_i_pair = min(
- n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps)
- )
- # Track "Yes to All" choice for this compute operation
- auto_interpolate_for_operation = False
+ pp_history = build_processing_parameters(name, "n-to-1", param=param)
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ name,
+ pp_history,
+ panel_str=self.panel.PANEL_STR_ID,
+ pairwise=pairwise,
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
- with create_progress_bar(self.panel, title, max_=n_pairs) as progress:
- for i_pair, src_obj1 in enumerate(src_objs[src_gids[0]][:max_i_pair]):
- progress.setValue(i_pair + 1)
- progress.setLabelText(title)
- src_objs_pair = [src_obj1]
- for src_gid in src_gids[1:]:
- src_obj = src_objs[src_gid][i_pair]
- src_objs_pair.append(src_obj)
-
- # Check signal x-array compatibility for n-to-1 operations
- if auto_interpolate_for_operation:
- # "Yes to All" selected, automatically interpolate
- # by temporarily changing the configuration
- with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ with self.mainwindow.historypanel.capture_outputs(action):
+ if pairwise:
+ src_grps, src_gids, src_objs, _nbobj, valid = (
+ self.__get_src_grps_gids_objs_nbobj_valid(min_group_nb=2)
+ )
+ if not valid:
+ return
+ dst_gname = (
+ f"{name}({','.join([get_short_id(grp) for grp in src_grps])})"
+ "|pairwise"
+ )
+ group_exclusive = len(self.panel.objview.get_sel_groups()) != 0
+ if not group_exclusive:
+ # This is not a group exclusive selection
+ dst_gname += "[...]"
+ # Delay group creation until after first result
+ # to determine target panel
+ dst_gid = None
+ n_pairs = len(src_objs[src_gids[0]])
+ max_i_pair = min(
+ n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps)
+ )
+ # Track "Yes to All" choice for this compute operation
+ auto_interpolate_for_operation = False
+
+ with create_progress_bar(self.panel, title, max_=n_pairs) as progress:
+ for i_pair, src_obj1 in enumerate(
+ src_objs[src_gids[0]][:max_i_pair]
+ ):
+ progress.setValue(i_pair + 1)
+ progress.setLabelText(title)
+ src_objs_pair = [src_obj1]
+ for src_gid in src_gids[1:]:
+ src_obj = src_objs[src_gid][i_pair]
+ src_objs_pair.append(src_obj)
+
+ # Check signal x-array compatibility for n-to-1 operations
+ if auto_interpolate_for_operation:
+ # "Yes to All" selected, automatically interpolate
+ # by temporarily changing the configuration
+ with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ result = self._check_signal_xarray_compatibility(
+ src_objs_pair, progress=progress
+ )
+ else:
+ # Normal compatibility check with dialog
result = self._check_signal_xarray_compatibility(
src_objs_pair, progress=progress
)
- else:
- # Normal compatibility check with dialog
- result = self._check_signal_xarray_compatibility(
- src_objs_pair, progress=progress
- )
- if result is None:
- # User canceled or compatibility check failed
- return
-
- checked_objs, yes_to_all_selected = result
- if yes_to_all_selected:
- auto_interpolate_for_operation = True
-
- src_objs_pair = checked_objs
- if param is None:
- args = (src_objs_pair,)
- else:
- args = (src_objs_pair, param)
- result = self.__exec_func(func, args, progress)
- if result is None:
- break
- new_obj = self.handle_output(
- result, _("Calculating: %s") % title, progress
- )
- if new_obj is None:
- break
- assert isinstance(new_obj, (SignalObj, ImageObj))
+ if result is None:
+ # User canceled or compatibility check failed
+ return
+
+ checked_objs, yes_to_all_selected = result
+ if yes_to_all_selected:
+ auto_interpolate_for_operation = True
+
+ src_objs_pair = checked_objs
+ if param is None:
+ args = (src_objs_pair,)
+ else:
+ args = (src_objs_pair, param)
+ result = self.__exec_func(func, args, progress)
+ if result is None:
+ break
+ new_obj = self.handle_output(
+ result, _("Calculating: %s") % title, progress
+ )
+ if new_obj is None:
+ break
+ assert isinstance(new_obj, (SignalObj, ImageObj))
- patch_title_with_ids(new_obj, src_objs_pair, get_short_id)
+ patch_title_with_ids(new_obj, src_objs_pair, get_short_id)
- # Handle keep_results and geometry result merging
- self._handle_keep_results(new_obj)
- self._merge_geometry_results_for_n_to_1(new_obj, src_objs_pair)
+ # Handle keep_results and geometry result merging
+ self._handle_keep_results(new_obj)
+ self._merge_geometry_results_for_n_to_1(new_obj, src_objs_pair)
- # Store lightweight processing metadata (non-interactive)
- proc_params = ProcessingParameters(
- func_name=name,
- pattern="n-to-1",
- param=param,
- source_uuids=[get_uuid(obj) for obj in src_objs_pair],
- )
- insert_processing_parameters(new_obj, proc_params)
+ # Store lightweight processing metadata (non-interactive)
+ proc_params = ProcessingParameters(
+ func_name=name,
+ pattern="n-to-1",
+ param=param,
+ source_uuids=[get_uuid(obj) for obj in src_objs_pair],
+ )
+ insert_processing_parameters(new_obj, proc_params)
- # Create destination group on first result, in appropriate panel
- if dst_gid is None:
- dst_gid = self._create_group_for_result(new_obj, dst_gname)
+ # Create destination group on first result, in appropriate panel
+ if dst_gid is None:
+ dst_gid = self._create_group_for_result(new_obj, dst_gname)
- self._add_object_to_appropriate_panel(new_obj, group_id=dst_gid)
+ self._add_object_to_appropriate_panel(new_obj, group_id=dst_gid)
- else:
- # In single operand mode, we create a single object for all selected objects
+ else:
+ # In single operand mode, we create a single object
+ # for all selected objects
+
+ # [src_objs dictionary] keys: old group id, values: list of old objects
+ src_objs: dict[str, list[SignalObj | ImageObj]] = {}
+
+ grps = self.panel.objview.get_sel_groups()
+ dst_group_name = None
+ if grps:
+ # (Group exclusive selection)
+ # At least one group is selected: create a new group
+ dst_gname = f"{name}({','.join([get_uuid(grp) for grp in grps])})"
+ # Delay group creation until after first result
+ dst_gid = None
+ dst_group_name = dst_gname # Store name for later use
+ else:
+ # (Object exclusive selection)
+ # No group is selected: use each object's group
+ dst_gid = None
- # [src_objs dictionary] keys: old group id, values: list of old objects
- src_objs: dict[str, list[SignalObj | ImageObj]] = {}
+ for src_obj in objs:
+ src_gid = objmodel.get_object_group_id(src_obj)
+ src_objs.setdefault(src_gid, []).append(src_obj)
- grps = self.panel.objview.get_sel_groups()
- dst_group_name = None
- if grps:
- # (Group exclusive selection)
- # At least one group is selected: create a new group
- dst_gname = f"{name}({','.join([get_uuid(grp) for grp in grps])})"
- # Delay group creation until after first result
- dst_gid = None
- dst_group_name = dst_gname # Store name for later use
- else:
- # (Object exclusive selection)
- # No group is selected: use each object's group
- dst_gid = None
+ # Track "Yes to All" choice for this compute operation
+ auto_interpolate_for_operation = False
- for src_obj in objs:
- src_gid = objmodel.get_object_group_id(src_obj)
- src_objs.setdefault(src_gid, []).append(src_obj)
-
- # Track "Yes to All" choice for this compute operation
- auto_interpolate_for_operation = False
-
- with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
- progress.setValue(0)
- progress.setLabelText(title)
- for src_gid, src_obj_list in src_objs.items():
- # Check signal x-array compatibility for n-to-1 operations
- if auto_interpolate_for_operation:
- # "Yes to All" selected, automatically interpolate
- with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
+ progress.setValue(0)
+ progress.setLabelText(title)
+ for src_gid, src_obj_list in src_objs.items():
+ # Check signal x-array compatibility for n-to-1 operations
+ if auto_interpolate_for_operation:
+ # "Yes to All" selected, automatically interpolate
+ with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ result = self._check_signal_xarray_compatibility(
+ src_obj_list, progress=progress
+ )
+ else:
+ # Normal compatibility check with dialog
result = self._check_signal_xarray_compatibility(
src_obj_list, progress=progress
)
- else:
- # Normal compatibility check with dialog
- result = self._check_signal_xarray_compatibility(
- src_obj_list, progress=progress
- )
- if result is None:
- # User canceled or compatibility check failed
- return
+ if result is None:
+ # User canceled or compatibility check failed
+ return
- checked_objs, yes_to_all_selected = result
- if yes_to_all_selected:
- auto_interpolate_for_operation = True
+ checked_objs, yes_to_all_selected = result
+ if yes_to_all_selected:
+ auto_interpolate_for_operation = True
- src_obj_list = checked_objs
+ src_obj_list = checked_objs
- if param is None:
- args = (src_obj_list,)
- else:
- args = (src_obj_list, param)
- result = self.__exec_func(func, args, progress)
- if result is None:
- break
- new_obj = self.handle_output(
- result, _("Calculating: %s") % title, progress
- )
- if new_obj is None:
- break
- assert isinstance(new_obj, (SignalObj, ImageObj))
+ if param is None:
+ args = (src_obj_list,)
+ else:
+ args = (src_obj_list, param)
+ result = self.__exec_func(func, args, progress)
+ if result is None:
+ break
+ new_obj = self.handle_output(
+ result, _("Calculating: %s") % title, progress
+ )
+ if new_obj is None:
+ break
+ assert isinstance(new_obj, (SignalObj, ImageObj))
- group_id = dst_gid if dst_gid is not None else src_gid
- patch_title_with_ids(new_obj, src_obj_list, get_short_id)
+ group_id = dst_gid if dst_gid is not None else src_gid
+ patch_title_with_ids(new_obj, src_obj_list, get_short_id)
- # Handle keep_results and geometry result merging
- self._handle_keep_results(new_obj)
- self._merge_geometry_results_for_n_to_1(new_obj, src_obj_list)
+ # Handle keep_results and geometry result merging
+ self._handle_keep_results(new_obj)
+ self._merge_geometry_results_for_n_to_1(new_obj, src_obj_list)
- # Store lightweight processing metadata (non-interactive)
- proc_params = ProcessingParameters(
- func_name=name,
- pattern="n-to-1",
- param=param,
- source_uuids=[get_uuid(obj) for obj in src_obj_list],
- )
- insert_processing_parameters(new_obj, proc_params)
+ # Store lightweight processing metadata (non-interactive)
+ proc_params = ProcessingParameters(
+ func_name=name,
+ pattern="n-to-1",
+ param=param,
+ source_uuids=[get_uuid(obj) for obj in src_obj_list],
+ )
+ insert_processing_parameters(new_obj, proc_params)
- # Create destination group on first result, in appropriate panel
- use_group_for_non_native = False
- if dst_gid is None and dst_group_name is not None:
- dst_gid = self._create_group_for_result(new_obj, dst_group_name)
- group_id = dst_gid
- use_group_for_non_native = True
+ # Create destination group on first result, in appropriate panel
+ use_group_for_non_native = False
+ if dst_gid is None and dst_group_name is not None:
+ dst_gid = self._create_group_for_result(
+ new_obj, dst_group_name
+ )
+ group_id = dst_gid
+ use_group_for_non_native = True
- self._add_object_to_appropriate_panel(
- new_obj,
- group_id=group_id,
- use_group_for_non_native=use_group_for_non_native,
- )
+ self._add_object_to_appropriate_panel(
+ new_obj,
+ group_id=group_id,
+ use_group_for_non_native=use_group_for_non_native,
+ )
- # Select newly created group, if any
- if dst_gid is not None:
- self.panel.objview.set_current_item_id(dst_gid)
+ # Select newly created group, if any
+ if dst_gid is not None:
+ self.panel.objview.set_current_item_id(dst_gid)
def compute_2_to_1(
self,
- obj2: SignalObj | ImageObj | list[SignalObj | ImageObj] | None,
+ obj2: SignalObj
+ | ImageObj
+ | list[SignalObj | ImageObj]
+ | int
+ | list[int]
+ | None,
obj2_name: str,
func: Callable,
param: gds.DataSet | None = None,
@@ -1740,8 +2190,9 @@ def compute_2_to_1(
comment: str | None = None,
edit: bool | None = None,
skip_xarray_compat: bool | None = None,
+ pairwise: bool | None = None,
) -> None:
- """Generic processing method: binary operation 1+1 → 1.
+ """Generic processing method: binary operation 1+1 → 1.
Applies a binary function between each selected object and a second operand.
Supports both single operand mode (same operand for all objects)
@@ -1778,7 +2229,7 @@ def compute_2_to_1(
objs = self.panel.objview.get_sel_objects(include_groups=True)
objmodel = self.panel.objmodel
- pairwise = is_pairwise_mode()
+ pairwise = is_pairwise_mode() if pairwise is None else pairwise
name = func.__name__
if obj2 is None:
@@ -1788,6 +2239,9 @@ def compute_2_to_1(
assert pairwise
else:
objs2 = [obj2]
+ if objs2 and all(isinstance(obj, int) for obj in objs2):
+ # If obj2 is a list of object numbers, convert to objects
+ objs2 = [objmodel.get_object_from_number(obj) for obj in objs2]
dlg_title = _("Select %s") % obj2_name
@@ -1812,83 +2266,219 @@ def compute_2_to_1(
if objs2 is None:
return
- n_pairs = len(src_objs[src_gids[0]])
- max_i_pair = min(
- n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps)
+ pp_history = build_processing_parameters(
+ func.__name__, "2-to-1", param=param
+ )
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or func.__name__,
+ pp_history,
+ panel_str=self.panel.PANEL_STR_ID,
+ obj2_uuids=[get_uuid(obj) for obj in objs2],
+ obj2_name=obj2_name,
+ pairwise=True,
+ plugin_origin=self._get_plugin_origin_for(func),
)
- grp2_id = objmodel.get_object_group_id(objs2[0])
- grp2 = objmodel.get_group(grp2_id)
-
- # Initialize pair mapping for potential interpolations
- pair_maps = {}
-
- # Check x-array compatibility for signal processing (pairwise mode)
- if self._is_signal_panel() and not skip_xarray_compat:
- # Check compatibility between objects from both groups
- all_pairs = []
- for src_gid in src_gids:
- for i_pair in range(max_i_pair):
- src_obj1 = src_objs[src_gid][i_pair]
- src_obj2 = objs2[i_pair]
- if isinstance(src_obj1, SignalObj) and isinstance(
- src_obj2, SignalObj
- ):
- all_pairs.append((src_obj1, src_obj2))
-
- # Track "Yes to All" choice for this compute operation
- auto_interpolate_for_operation = False
- # Check all pairs for compatibility and create interpolation maps
- for src_obj1, src_obj2 in all_pairs:
- if auto_interpolate_for_operation:
- # "Yes to All" selected, automatically interpolate
- with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ with self.mainwindow.historypanel.capture_outputs(action):
+ n_pairs = len(src_objs[src_gids[0]])
+ max_i_pair = min(
+ n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps)
+ )
+ grp2_id = objmodel.get_object_group_id(objs2[0])
+ grp2 = objmodel.get_group(grp2_id)
+
+ # Initialize pair mapping for potential interpolations
+ pair_maps = {}
+
+ # Check x-array compatibility for signal processing (pairwise mode)
+ if self._is_signal_panel() and not skip_xarray_compat:
+ # Check compatibility between objects from both groups
+ all_pairs = []
+ for src_gid in src_gids:
+ for i_pair in range(max_i_pair):
+ src_obj1 = src_objs[src_gid][i_pair]
+ src_obj2 = objs2[i_pair]
+ if isinstance(src_obj1, SignalObj) and isinstance(
+ src_obj2, SignalObj
+ ):
+ all_pairs.append((src_obj1, src_obj2))
+
+ # Track "Yes to All" choice for this compute operation
+ auto_interpolate_for_operation = False
+
+ # Check all pairs for compatibility and create interpolation maps
+ for src_obj1, src_obj2 in all_pairs:
+ if auto_interpolate_for_operation:
+ # "Yes to All" selected, automatically interpolate
+ with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ result = self._check_signal_xarray_compatibility(
+ [src_obj1, src_obj2]
+ )
+ else:
+ # Normal compatibility check with dialog
result = self._check_signal_xarray_compatibility(
[src_obj1, src_obj2]
)
- else:
- # Normal compatibility check with dialog
+
+ if result is None:
+ return # User cancelled or error occurred
+
+ checked_pair, yes_to_all_selected = result
+ if yes_to_all_selected:
+ auto_interpolate_for_operation = True
+
+ # Store mapping for this specific pair
+ pair_maps[(src_obj1, src_obj2)] = checked_pair
+
+ with create_progress_bar(
+ self.panel, title, max_=len(src_gids)
+ ) as progress:
+ for i_group, src_gid in enumerate(src_gids):
+ progress.setValue(i_group + 1)
+ progress.setLabelText(title)
+ if group_exclusive:
+ # This is a group exclusive selection
+ src_grp = objmodel.get_group(src_gid)
+ grp_short_ids = [get_uuid(grp) for grp in (src_grp, grp2)]
+ dst_gname = f"{name}({','.join(grp_short_ids)})|pairwise"
+ else:
+ dst_gname = f"{name}[...]"
+ # Delay group creation until after first result
+ dst_gid = None
+ for i_pair in range(max_i_pair):
+ orig_obj1 = src_objs[src_gid][i_pair]
+ orig_obj2 = objs2[i_pair]
+
+ # Use interpolated signals if available, keep original refs
+ actual_obj1, actual_obj2 = orig_obj1, orig_obj2
+ if (orig_obj1, orig_obj2) in pair_maps:
+ interpolated_pair = pair_maps[(orig_obj1, orig_obj2)]
+ actual_obj1 = interpolated_pair[0]
+ actual_obj2 = interpolated_pair[1]
+
+ args = [actual_obj1, actual_obj2]
+ if param is not None:
+ args.append(param)
+ result = self.__exec_func(func, tuple(args), progress)
+ if result is None:
+ break
+ new_obj = self.handle_output(
+ result, _("Calculating: %s") % title, progress
+ )
+ if new_obj is None:
+ continue
+ assert isinstance(new_obj, (SignalObj, ImageObj))
+
+ # Use original objects for title generation
+ patch_title_with_ids(
+ new_obj, [orig_obj1, orig_obj2], get_short_id
+ )
+
+ # Handle keep_results logic for 2_to_1 operations
+ self._handle_keep_results(new_obj)
+
+ # Store lightweight processing metadata (non-interactive)
+ proc_params = ProcessingParameters(
+ func_name=name,
+ pattern="2-to-1",
+ param=param,
+ source_uuids=[
+ get_uuid(orig_obj1),
+ get_uuid(orig_obj2),
+ ],
+ )
+ insert_processing_parameters(new_obj, proc_params)
+
+ # Create dest group on first result
+ if dst_gid is None:
+ dst_gid = self._create_group_for_result(
+ new_obj, dst_gname
+ )
+
+ self._add_object_to_appropriate_panel(
+ new_obj, group_id=dst_gid
+ )
+
+ else:
+ if not objs2:
+ objs2 = self.panel.get_objects_with_dialog(
+ dlg_title,
+ _(
+ "Note: operation mode is single operand: "
+ "1 object expected"
+ ),
+ )
+ if objs2 is None:
+ return
+ obj2 = objs2[0]
+
+ pp_history = build_processing_parameters(
+ func.__name__, "2-to-1", param=param
+ )
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or func.__name__,
+ pp_history,
+ panel_str=self.panel.PANEL_STR_ID,
+ obj2_uuids=[get_uuid(obj2)],
+ obj2_name=obj2_name,
+ pairwise=False,
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+
+ with self.mainwindow.historypanel.capture_outputs(action):
+ # Initialize signal mapping for potential interpolations
+ signal_map = {}
+
+ # Check x-array compatibility for signal processing
+ # (single operand mode)
+ orig_obj2 = obj2 # Keep reference to original obj2 for title generation
+ if (
+ self._is_signal_panel()
+ and isinstance(obj2, SignalObj)
+ and not skip_xarray_compat
+ ):
+ signal_objs = [obj for obj in objs if isinstance(obj, SignalObj)]
+ if signal_objs:
+ # Check compatibility and get potentially interpolated signals
result = self._check_signal_xarray_compatibility(
- [src_obj1, src_obj2]
+ signal_objs + [obj2]
)
+ if result is None:
+ return # User cancelled or error occurred
- if result is None:
- return # User cancelled or error occurred
+ checked_objs, _yes_to_all_selected = result
+ # Note: In single operand mode, "Yes to All" doesn't apply
+ # since there's only one compatibility check
- checked_pair, yes_to_all_selected = result
- if yes_to_all_selected:
- auto_interpolate_for_operation = True
+ # Replace obj2 with the potentially interpolated version
+ obj2 = checked_objs[-1] # obj2 was added last
- # Store mapping for this specific pair
- pair_maps[(src_obj1, src_obj2)] = checked_pair
+ # Create a mapping of original to interpolated signals
+ for orig_obj, checked_obj in zip(
+ signal_objs, checked_objs[:-1]
+ ):
+ signal_map[orig_obj] = checked_obj
+
+ with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
+ for index, obj in enumerate(objs):
+ progress.setValue(index + 1)
+ progress.setLabelText(title)
+
+ # Use interpolated signal if available
+ actual_obj = obj
+ if (
+ self._is_signal_panel()
+ and isinstance(obj, SignalObj)
+ and obj in signal_map
+ ):
+ actual_obj = signal_map[obj]
- with create_progress_bar(self.panel, title, max_=len(src_gids)) as progress:
- for i_group, src_gid in enumerate(src_gids):
- progress.setValue(i_group + 1)
- progress.setLabelText(title)
- if group_exclusive:
- # This is a group exclusive selection
- src_grp = objmodel.get_group(src_gid)
- grp_short_ids = [get_uuid(grp) for grp in (src_grp, grp2)]
- dst_gname = f"{name}({','.join(grp_short_ids)})|pairwise"
- else:
- dst_gname = f"{name}[...]"
- # Delay group creation until after first result
- dst_gid = None
- for i_pair in range(max_i_pair):
- orig_obj1, orig_obj2 = src_objs[src_gid][i_pair], objs2[i_pair]
-
- # Use interpolated signals if available, keep original refs
- actual_obj1, actual_obj2 = orig_obj1, orig_obj2
- if (orig_obj1, orig_obj2) in pair_maps:
- interpolated_pair = pair_maps[(orig_obj1, orig_obj2)]
- actual_obj1 = interpolated_pair[0]
- actual_obj2 = interpolated_pair[1]
-
- args = [actual_obj1, actual_obj2]
- if param is not None:
- args.append(param)
- result = self.__exec_func(func, tuple(args), progress)
+ args = (
+ (actual_obj, obj2)
+ if param is None
+ else (actual_obj, obj2, param)
+ )
+ result = self.__exec_func(func, args, progress)
if result is None:
break
new_obj = self.handle_output(
@@ -1898,10 +2488,9 @@ def compute_2_to_1(
continue
assert isinstance(new_obj, (SignalObj, ImageObj))
+ group_id = objmodel.get_object_group_id(obj)
# Use original objects for title generation
- patch_title_with_ids(
- new_obj, [orig_obj1, orig_obj2], get_short_id
- )
+ patch_title_with_ids(new_obj, [obj, orig_obj2], get_short_id)
# Handle keep_results logic for 2_to_1 operations
self._handle_keep_results(new_obj)
@@ -1912,113 +2501,17 @@ def compute_2_to_1(
pattern="2-to-1",
param=param,
source_uuids=[
- get_uuid(orig_obj1),
+ get_uuid(obj),
get_uuid(orig_obj2),
],
)
insert_processing_parameters(new_obj, proc_params)
- # Create destination group on first result, in appropriate panel
- if dst_gid is None:
- dst_gid = self._create_group_for_result(new_obj, dst_gname)
-
- self._add_object_to_appropriate_panel(new_obj, group_id=dst_gid)
-
- else:
- if not objs2:
- objs2 = self.panel.get_objects_with_dialog(
- dlg_title,
- _(
- "Note: operation mode is single operand: "
- "1 object expected"
- ),
- )
- if objs2 is None:
- return
- obj2 = objs2[0]
-
- # Initialize signal mapping for potential interpolations
- signal_map = {}
-
- # Check x-array compatibility for signal processing (single operand mode)
- orig_obj2 = obj2 # Keep reference to original obj2 for title generation
- if (
- self._is_signal_panel()
- and isinstance(obj2, SignalObj)
- and not skip_xarray_compat
- ):
- signal_objs = [obj for obj in objs if isinstance(obj, SignalObj)]
- if signal_objs:
- # Check compatibility and get potentially interpolated signals
- result = self._check_signal_xarray_compatibility(
- signal_objs + [obj2]
- )
- if result is None:
- return # User cancelled or error occurred
-
- checked_objs, _yes_to_all_selected = result
- # Note: In single operand mode, "Yes to All" doesn't apply
- # since there's only one compatibility check
-
- # Replace obj2 with the potentially interpolated version
- obj2 = checked_objs[-1] # obj2 was added last
-
- # Create a mapping of original to interpolated signals
- for orig_obj, checked_obj in zip(signal_objs, checked_objs[:-1]):
- signal_map[orig_obj] = checked_obj
-
- with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
- for index, obj in enumerate(objs):
- progress.setValue(index + 1)
- progress.setLabelText(title)
-
- # Use interpolated signal if available
- actual_obj = obj
- if (
- self._is_signal_panel()
- and isinstance(obj, SignalObj)
- and obj in signal_map
- ):
- actual_obj = signal_map[obj]
-
- args = (
- (actual_obj, obj2)
- if param is None
- else (actual_obj, obj2, param)
- )
- result = self.__exec_func(func, args, progress)
- if result is None:
- break
- new_obj = self.handle_output(
- result, _("Calculating: %s") % title, progress
- )
- if new_obj is None:
- continue
- assert isinstance(new_obj, (SignalObj, ImageObj))
-
- group_id = objmodel.get_object_group_id(obj)
- # Use original objects for title generation
- patch_title_with_ids(new_obj, [obj, orig_obj2], get_short_id)
-
- # Handle keep_results logic for 2_to_1 operations
- self._handle_keep_results(new_obj)
-
- # Store lightweight processing metadata (non-interactive)
- proc_params = ProcessingParameters(
- func_name=name,
- pattern="2-to-1",
- param=param,
- source_uuids=[
- get_uuid(obj),
- get_uuid(orig_obj2),
- ],
- )
- insert_processing_parameters(new_obj, proc_params)
-
- # group_id is from source panel, don't use for non-native objects
- self._add_object_to_appropriate_panel(
- new_obj, group_id=group_id, use_group_for_non_native=False
- )
+ # group_id is from source panel, don't use
+ # for non-native objects
+ self._add_object_to_appropriate_panel(
+ new_obj, group_id=group_id, use_group_for_non_native=False
+ )
def register_1_to_1(
self,
@@ -2210,27 +2703,66 @@ def register_2_to_1(
def add_feature(self, feature: ComputingFeature) -> None:
"""Add a computing feature to the registry.
+ Auto-detects the plugin origin from ``feature.function.__module__`` and
+ stores it on the feature (see :func:`_detect_plugin_origin`).
+
Args:
feature: ComputingFeature instance to add.
"""
+ if feature.function is not None and feature.plugin_origin is None:
+ feature.plugin_origin = _detect_plugin_origin(feature.function)
self.computing_registry[feature.function] = feature
- def get_feature(self, function_or_name: Callable | str) -> ComputingFeature:
+ def _get_plugin_origin_for(self, func: Callable) -> dict[str, Any] | None:
+ """Return the plugin origin descriptor for ``func`` if known.
+
+ Falls back to a fresh detection if ``func`` is not in the registry.
+
+ Args:
+ func: Computation function.
+
+ Returns:
+ Plugin origin dict, or ``None`` for built-in functions.
+ """
+ feature = self.computing_registry.get(func)
+ if feature is not None:
+ return feature.plugin_origin
+ return _detect_plugin_origin(func)
+
+ def get_feature(
+ self,
+ function_or_name: Callable | str,
+ plugin_origin: dict[str, Any] | None = None,
+ paramclass_name: str | None = None,
+ ) -> ComputingFeature:
"""Get a computing feature by name or function.
Args:
function_or_name: Name of the feature or the function itself.
+ plugin_origin: Optional plugin origin descriptor used to enrich the
+ :class:`FeatureNotFoundError` raised when the feature is unknown.
+ paramclass_name: Optional name of the required parameter class, also
+ used to enrich the error message.
Returns:
Computing feature instance.
+
+ Raises:
+ FeatureNotFoundError: If no matching feature is registered. The
+ exception subclasses :class:`ValueError` to preserve backward
+ compatibility with existing callers.
"""
try:
return self.computing_registry[function_or_name]
- except KeyError as exc:
+ except KeyError:
for _func, feature in self.computing_registry.items():
if feature.name == function_or_name:
return feature
- raise ValueError(f"Unknown computing feature: {function_or_name}") from exc
+ raise FeatureNotFoundError(
+ str(function_or_name),
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
@qt_try_except()
def run_feature(
@@ -2297,6 +2829,9 @@ def run_feature(
assert isinstance(param, (gds.DataSet, type(None))), (
f"For pattern '{pattern}', 'param' must be a DataSet or None"
)
+ compute_kwargs = {}
+ if pattern == "n_to_1":
+ compute_kwargs["pairwise"] = kwargs.pop("pairwise", None)
return compute_method(
feature.function,
param=param,
@@ -2304,6 +2839,7 @@ def run_feature(
title=title,
comment=comment,
edit=edit,
+ **compute_kwargs,
)
if pattern == "2_to_1":
obj2 = kwargs.pop("obj2", args[0] if args else None)
@@ -2315,6 +2851,7 @@ def run_feature(
assert isinstance(param, (gds.DataSet, type(None))), (
"For pattern '2_to_1', 'param' must be a DataSet or None"
)
+ pairwise = kwargs.pop("pairwise", None)
return self.compute_2_to_1(
obj2,
feature.obj2_name or _("Second operand"),
@@ -2325,6 +2862,7 @@ def run_feature(
comment=comment,
edit=edit,
skip_xarray_compat=feature.skip_xarray_compat,
+ pairwise=pairwise,
)
if pattern == "1_to_n":
params = kwargs.get("params", args[0] if args else [])
diff --git a/datalab/h5/history.py b/datalab/h5/history.py
new file mode 100644
index 00000000..0b5b39c1
--- /dev/null
+++ b/datalab/h5/history.py
@@ -0,0 +1,264 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""History panel HDF5 import/export and persistence helpers."""
+
+from __future__ import annotations
+
+import os.path as osp
+from typing import TYPE_CHECKING, Any
+from uuid import uuid4
+
+from qtpy.compat import getopenfilename, getsavefilename
+
+from datalab.config import Conf, _
+from datalab.gui.processor.base import (
+ PROCESSING_PARAMETERS_OPTION,
+ ProcessingParameters,
+)
+from datalab.h5.native import NativeH5Reader, NativeH5Writer
+from datalab.history import HistorySession
+from datalab.objectmodel import get_uuid
+from datalab.utils.qthelpers import qt_try_loadsave_file, save_restore_stds
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.history import HistoryPanel
+
+
+def save_to_dlhist_file(panel: HistoryPanel, filename: str | None = None) -> bool:
+ """Save the History Panel content to a standalone ``.dlhist`` file.
+
+ Args:
+ filename: History filename. If None, a file dialog is opened.
+
+ Returns:
+ True if the history was saved, False if the operation was canceled.
+ """
+ if filename is None:
+ basedir = Conf.main.base_dir.get()
+ with save_restore_stds():
+ filename, _filt = getsavefilename(
+ panel, _("Save history file"), basedir, panel.FILE_FILTERS
+ )
+ if not filename:
+ return False
+ if osp.splitext(filename)[1] == "":
+ filename += ".dlhist"
+ with qt_try_loadsave_file(panel.parentWidget(), filename, "save"):
+ Conf.main.base_dir.set(filename)
+ with NativeH5Writer(filename) as writer:
+ # Make the .dlhist file panel-contained: store the signal and
+ # image panel objects (all of them) alongside the history, so
+ # that reopening restores both the data objects and the history
+ # that references them. Each section is read back by its own
+ # H5_PREFIX key, so the write order is not significant.
+ panel.mainwindow.signalpanel.serialize_to_hdf5(writer)
+ panel.mainwindow.imagepanel.serialize_to_hdf5(writer)
+ panel.serialize_to_hdf5(writer)
+ return True
+
+
+def open_dlhist_file(panel: HistoryPanel, filename: str | None = None) -> bool:
+ """Open a standalone ``.dlhist`` file into the History Panel.
+
+ Args:
+ filename: History filename. If None, a file dialog is opened.
+
+ Returns:
+ True if the history was loaded, False if the operation was canceled.
+ """
+ if filename is None:
+ basedir = Conf.main.base_dir.get()
+ with save_restore_stds():
+ filename, _filt = getopenfilename(
+ panel, _("Open history file"), basedir, panel.FILE_FILTERS
+ )
+ if not filename:
+ return False
+ with qt_try_loadsave_file(panel.parentWidget(), filename, "load"):
+ Conf.main.base_dir.set(filename)
+ with NativeH5Reader(filename) as reader:
+ # A panel-contained .dlhist file stores the signal and image
+ # panel objects in addition to the history sessions. The way
+ # they are restored depends on whether the workspace is already
+ # in use (data objects OR history): a pristine workspace is
+ # loaded directly while preserving UUIDs, otherwise the file
+ # is imported as new groups/sessions.
+ workspace_in_use = (
+ panel.mainwindow.signalpanel.objmodel.get_object_ids()
+ or panel.mainwindow.imagepanel.objmodel.get_object_ids()
+ or bool(panel.history_sessions)
+ )
+ if workspace_in_use:
+ # Workspace not empty: import the objects into new groups
+ # with fresh UUIDs and append the history as new sessions
+ # whose references are remapped to the imported objects.
+ panel.import_dlhist_into_new_session(reader)
+ else:
+ # Workspace empty: load directly, preserving original UUIDs
+ # (reset_all=True) so that history references stay valid.
+ panel.mainwindow.signalpanel.deserialize_from_hdf5(
+ reader, reset_all=True
+ )
+ panel.mainwindow.imagepanel.deserialize_from_hdf5(
+ reader, reset_all=True
+ )
+ panel.deserialize_from_hdf5(reader)
+ return True
+
+
+def import_dlhist_into_new_session(panel: HistoryPanel, reader: NativeH5Reader) -> None:
+ """Import a ``.dlhist`` file into new groups and new history sessions.
+
+ Used when the workspace already contains objects: the file's signal and
+ image objects are imported into fresh groups with regenerated UUIDs, and
+ the history sessions are appended as new independent sessions whose action
+ references are remapped to the freshly imported objects.
+
+ Args:
+ reader: HDF5 reader positioned on a ``.dlhist`` file.
+ """
+ panel_map = {
+ "signal": panel.mainwindow.signalpanel,
+ "image": panel.mainwindow.imagepanel,
+ }
+ uuid_remap: dict[str, dict[str, str]] = {}
+ imported_by_pstr: dict[str, list] = {}
+ # 1. Import objects from each panel (each panel is read by its own
+ # H5_PREFIX key). Read each object preserving its original UUID to
+ # capture the old->new mapping, then assign a fresh UUID so that the
+ # imported objects keep an independent identity.
+ for pstr, data_panel in panel_map.items():
+ uuid_remap[pstr] = {}
+ imported: list = []
+ imported_by_pstr[pstr] = imported
+ if data_panel.H5_PREFIX not in reader.h5:
+ continue
+ with reader.group(data_panel.H5_PREFIX):
+ for name in reader.h5.get(data_panel.H5_PREFIX, []):
+ with reader.group(name):
+ group = data_panel.add_group("")
+ with reader.group("title"):
+ group.title = reader.read_str()
+ for obj_name in reader.h5.get(f"{data_panel.H5_PREFIX}/{name}", []):
+ obj = data_panel.deserialize_object_from_hdf5(
+ reader, obj_name, reset_all=True
+ )
+ old_uuid = get_uuid(obj)
+ new_uuid = str(uuid4())
+ # SignalObj/ImageObj store UUID via metadata option
+ try:
+ obj.set_metadata_option("uuid", new_uuid)
+ except AttributeError:
+ obj.uuid = new_uuid
+ uuid_remap[pstr][old_uuid] = new_uuid
+ data_panel.add_object(obj, get_uuid(group), set_current=False)
+ imported.append(obj)
+ data_panel.selection_changed()
+ # 2. Remap source UUIDs in imported objects' processing_parameters so
+ # that reprocessing in the Processing tab uses the imported sources,
+ # not the originals (same logic as duplicate_selected_entries).
+ for pstr, objs in imported_by_pstr.items():
+ pmap = uuid_remap.get(pstr, {})
+ if not pmap:
+ continue
+ for obj in objs:
+ try:
+ pp_dict = obj.get_metadata_option(PROCESSING_PARAMETERS_OPTION)
+ except (AttributeError, ValueError):
+ continue
+ if not pp_dict:
+ continue
+ try:
+ pp = ProcessingParameters.from_dict(pp_dict)
+ except (TypeError, ValueError, AttributeError):
+ continue
+ changed = False
+ if pp.source_uuid is not None and pp.source_uuid in pmap:
+ pp.source_uuid = pmap[pp.source_uuid]
+ changed = True
+ if pp.source_uuids is not None:
+ new_src = [pmap.get(u, u) for u in pp.source_uuids]
+ if new_src != pp.source_uuids:
+ pp.source_uuids = new_src
+ changed = True
+ if changed:
+ try:
+ obj.set_metadata_option(PROCESSING_PARAMETERS_OPTION, pp.to_dict())
+ except (AttributeError, ValueError):
+ pass
+ # 3. Import history sessions as new independent sessions whose captured
+ # UUIDs are remapped to the imported objects.
+ if panel.H5_PREFIX not in reader.h5:
+ return
+ sessions = reader.read_object_list(panel.H5_PREFIX, HistorySession) or []
+ imported_suffix = _("Imported")
+ new_sessions: list[HistorySession] = []
+ for session in sessions:
+ panel.session_increment += 1
+ title = f"{session.title} {imported_suffix}"
+ new_session = session.copy_with_uuid_remap(title=title, uuid_remap=uuid_remap)
+ new_session.number = panel.session_increment
+ new_sessions.append(new_session)
+ # Register output mappings for imported actions so that
+ # resolve_target_outputs / get_downstream_actions work.
+ for action in new_session.actions:
+ if action.output_uuids:
+ panel.action_output_uuids[action.uuid] = list(action.output_uuids)
+ for out_uuid in action.output_uuids:
+ panel.output_to_action[out_uuid] = action.uuid
+ panel.history_sessions.extend(new_sessions)
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.update_actions_state()
+
+
+def refresh_compatibility_items(panel: HistoryPanel, *args: Any) -> None:
+ """Refresh action item compatibility markers in the tree."""
+ del args
+ panel.tree.update_compatibility_states(panel.history_sessions, panel.mainwindow)
+
+
+def serialize_to_hdf5(panel: HistoryPanel, writer: NativeH5Writer) -> None:
+ """Serialize whole panel to a HDF5 file
+
+ Args:
+ writer: HDF5 writer
+ """
+ writer.write_object_list(panel.history_sessions, panel.H5_PREFIX)
+
+
+def deserialize_from_hdf5(
+ panel: HistoryPanel, reader: NativeH5Reader, reset_all: bool = False
+) -> None:
+ """Deserialize whole panel from a HDF5 file
+
+ Args:
+ reader: HDF5 reader
+ reset_all: Unused (kept for compatibility with panel API)
+ """
+ del reset_all # required by the polymorphic panel API; unused here
+ if panel.H5_PREFIX not in reader.h5:
+ panel.history_sessions = []
+ panel.session_increment = 0
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.update_actions_state()
+ return
+ panel.history_sessions = (
+ reader.read_object_list(panel.H5_PREFIX, HistorySession) or []
+ )
+ if panel.history_sessions:
+ panel.session_increment = panel.history_sessions[-1].number
+ # Rebuild the bijective mapping from the loaded actions. Legacy
+ # (v1) actions have empty ``output_uuids`` and contribute nothing
+ # to the index — the heuristic fallback handles them.
+ panel.action_output_uuids = {}
+ panel.output_to_action = {}
+ for session in panel.history_sessions:
+ for action in session.actions:
+ if action.output_uuids:
+ panel.action_output_uuids[action.uuid] = list(action.output_uuids)
+ for out_uuid in action.output_uuids:
+ panel.output_to_action[out_uuid] = action.uuid
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.update_actions_state()
diff --git a/datalab/h5/native.py b/datalab/h5/native.py
index 84419e4e..5004c577 100644
--- a/datalab/h5/native.py
+++ b/datalab/h5/native.py
@@ -8,11 +8,18 @@
from __future__ import annotations
+import importlib
+from typing import Any, Callable
+
from guidata.io import HDF5Reader, HDF5Writer
+from guidata.io.h5fmt import NoDefault
import datalab
DATALAB_VERSION_NAME = "DataLab_Version"
+DATALAB_PACKAGE_NAME = "datalab"
+
+H5_CALLABLE_PREFIX = "#callable#"
class NativeH5Writer(HDF5Writer):
@@ -26,6 +33,45 @@ def __init__(self, filename: str) -> None:
super().__init__(filename)
self.h5[DATALAB_VERSION_NAME] = datalab.__version__
+ @staticmethod
+ def serialize_func_or_class(obj: Callable | type) -> str:
+ """Serialize a function or a class object
+
+ Args:
+ obj: Object to serialize
+
+ Returns:
+ str: Serialized object
+ """
+ if not obj.__module__.startswith(DATALAB_PACKAGE_NAME):
+ raise ValueError(
+ f"Only {DATALAB_PACKAGE_NAME} functions and classes can be serialized"
+ )
+ val = f"{H5_CALLABLE_PREFIX}{obj.__module__}."
+ if isinstance(obj, type):
+ return val + obj.__name__
+ return val + obj.__qualname__
+
+ # Reimplement the write method to handle callable objects
+ def write(self, val: Any, group_name: str | None = None) -> None:
+ """
+ Write a value depending on its type, optionally within a named group.
+
+ Args:
+ val: The value to be written.
+ group_name: The name of the group. If provided, the group
+ context will be used for writing the value.
+ """
+ try:
+ super().write(val, group_name)
+ except NotImplementedError:
+ if callable(val):
+ super().write_str(self.serialize_func_or_class(val))
+ if group_name:
+ self.end(group_name)
+ else:
+ raise
+
class NativeH5Reader(HDF5Reader):
"""DataLab signal/image objects HDF5 guidata dataset Writer class
@@ -37,3 +83,63 @@ class NativeH5Reader(HDF5Reader):
def __init__(self, filename: str) -> None:
super().__init__(filename)
self.version = self.h5[DATALAB_VERSION_NAME]
+
+ @staticmethod
+ def deserialize_func_or_class(obj: str) -> Callable | type:
+ """Deserialize a function or a class object
+
+ Args:
+ obj: Serialized object
+
+ Returns:
+ Callable | type: Deserialized object
+ """
+ parts = obj[len(H5_CALLABLE_PREFIX) :].split(".")
+ if not parts or not parts[0].startswith(DATALAB_PACKAGE_NAME):
+ raise ValueError(
+ f"Only {DATALAB_PACKAGE_NAME} functions and classes can be deserialized"
+ )
+ # Walk path parts: find longest valid module prefix, then resolve the
+ # remaining attribute chain (supports methods like ``Class.method``).
+ for split_index in range(len(parts) - 1, 0, -1):
+ module_name = ".".join(parts[:split_index])
+ try:
+ module = importlib.import_module(module_name)
+ except ImportError:
+ continue
+ attr: Any = module
+ try:
+ for name in parts[split_index:]:
+ attr = getattr(attr, name)
+ except AttributeError:
+ continue
+ return attr
+ raise ImportError(f"Cannot deserialize callable: {obj}")
+
+ # Reimplement the read method to handle callable objects
+ def read(
+ self,
+ group_name: str | None = None,
+ func: Callable[[], Any] | None = None,
+ instance: Any | None = None,
+ default: Any | NoDefault = NoDefault,
+ ) -> Any:
+ """
+ Read a value from the current group or specified group_name.
+
+ Args:
+ group_name: The name of the group to read from. Defaults to None.
+ func: The function to use for reading the value. Defaults to None.
+ instance: An object that implements the DataSet-like `deserialize` method.
+ Defaults to None.
+ default: The default value to return if the value is not found.
+ Defaults to `NoDefault` (no default value: raises an exception if the
+ value is not found).
+
+ Returns:
+ The read value.
+ """
+ val = super().read(group_name, func=func, instance=instance, default=default)
+ if isinstance(val, str) and val.startswith(H5_CALLABLE_PREFIX):
+ return self.deserialize_func_or_class(val)
+ return val
diff --git a/datalab/history/__init__.py b/datalab/history/__init__.py
new file mode 100644
index 00000000..2ecb73c2
--- /dev/null
+++ b/datalab/history/__init__.py
@@ -0,0 +1,23 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""DataLab history model package (pure data model, no Qt widgets)."""
+
+from datalab.history.action import HistoryAction
+from datalab.history.core import (
+ HISTORY_ACTION_SCHEMA_VERSION,
+ HISTORY_SCHEMA_VERSION,
+ add_to_history,
+ get_datetime_str,
+)
+from datalab.history.session import HistorySession
+from datalab.history.workspace_state import WorkspaceState
+
+__all__ = [
+ "HISTORY_ACTION_SCHEMA_VERSION",
+ "HISTORY_SCHEMA_VERSION",
+ "HistoryAction",
+ "HistorySession",
+ "WorkspaceState",
+ "add_to_history",
+ "get_datetime_str",
+]
diff --git a/datalab/history/action.py b/datalab/history/action.py
new file mode 100644
index 00000000..f415367f
--- /dev/null
+++ b/datalab/history/action.py
@@ -0,0 +1,780 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""HistoryAction model: serialisable description of one recorded operation."""
+
+from __future__ import annotations
+
+import html
+import inspect
+import json
+import logging
+import os
+from contextlib import nullcontext
+from typing import TYPE_CHECKING, Any, Callable, Generator
+from uuid import uuid4
+
+import sigima.proc.image
+import sigima.proc.signal
+from guidata.dataset.datatypes import DataSet
+
+from datalab.config import _
+from datalab.gui import ObjItf
+from datalab.history.core import (
+ HISTORY_ACTION_SCHEMA_VERSION,
+ HISTORY_SCHEMA_VERSION,
+ _copy_history_value,
+ _decode_kwargs,
+ _encode_kwargs,
+ get_datetime_str,
+)
+from datalab.history.workspace_state import WorkspaceState
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+ from datalab.h5.native import NativeH5Reader, NativeH5Writer
+
+_logger = logging.getLogger(__name__)
+
+
+class HistoryAction(ObjItf):
+ """Object representing an action in the history panel.
+
+ An action is a serialisable description of either a *compute* call (resolved
+ via the panel processor's feature registry) or a *UI* call (resolved as a
+ method on a known target -- ``mainwindow``/``signalpanel``/``imagepanel``).
+
+ No Python ``Callable`` is ever pickled: a compute action is identified by
+ ``(panel_str, func_name, pattern)`` and a UI action by ``(target,
+ method_name)``. ``DataSet`` payloads inside ``kwargs`` are serialised with
+ :func:`guidata.dataset.conv.dataset_to_json`.
+ """
+
+ KIND_COMPUTE = "compute"
+ KIND_UI = "ui"
+
+ FUNC_EDIT_MODE = "edit" # Name of the function parameter to enable edit mode
+ # Methods that create new data objects. During non-persistent (output-suppressed)
+ # replay, these UI actions are skipped so the panel object count stays stable.
+ UI_CREATION_METHODS: frozenset[str] = frozenset({"new_object"})
+ # UI methods that destroy data objects. Replaying these requires that the
+ # captured selection still resolves to existing objects (see ``_replay_ui``).
+ DESTRUCTIVE_METHODS: frozenset[str] = frozenset(
+ {"remove_object", "remove_group", "delete_all_objects"}
+ )
+
+ def __init__(
+ self,
+ title: str = "",
+ kind: str = KIND_UI,
+ # --- compute-only --------------------------------------------------
+ panel_str: str | None = None,
+ func_name: str | None = None,
+ pattern: str | None = None,
+ # --- ui-only -------------------------------------------------------
+ target: str | None = None,
+ method_name: str | None = None,
+ # --- common --------------------------------------------------------
+ kwargs: dict[str, Any] | None = None,
+ state: WorkspaceState | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> None:
+ super().__init__()
+ self.__title = title or ""
+ self.kind = kind
+ # Compute kind:
+ self.panel_str = panel_str
+ self.func_name = func_name
+ self.pattern = pattern
+ # UI kind:
+ self.target = target
+ self.method_name = method_name
+ # Common:
+ self.kwargs: dict[str, Any] = (
+ {} if kwargs is None else {k: v for k, v in kwargs.items() if v is not None}
+ )
+ self.state = WorkspaceState() if state is None else state
+ self.dtstr: str = get_datetime_str()
+ self.uuid: str = str(uuid4())
+ self.schema_version: int = HISTORY_ACTION_SCHEMA_VERSION
+ # UUIDs of the data objects produced by this action (bijective mapping
+ # maintained by :class:`HistoryPanel`). Populated post-compute via
+ # :meth:`HistoryPanel.register_action_outputs`. Empty for ``1_to_0``
+ # patterns, for UI actions, and for sessions loaded without output info
+ # loaded from disk (the heuristic fallback then takes over).
+ self.output_uuids: list[str] = []
+ # Plugin origin descriptor for compute actions (None for built-in
+ # Sigima/DataLab features). Populated at registration time by
+ # :meth:`BaseProcessor.add_feature` and propagated through
+ # ``add_compute_entry_from_pp``. See
+ # :func:`datalab.gui.processor.base._detect_plugin_origin` for shape.
+ # Persisted as a JSON string in HDF5.
+ self.plugin_origin: dict[str, Any] | None = plugin_origin
+ # Transient flag (NOT serialized): set during a cascade recompute to
+ # display a "stale" visual marker in the tree. Cleared once the
+ # action has been recomputed.
+ self.is_stale: bool = False
+ # Snapshot of original kwargs before edit-mode modification.
+ # Set lazily when the first edit-mode change touches this action.
+ # Persisted to HDF5 so that the Restore
+ # action still works after a save/reload cycle while Edit mode is
+ # active. Cleared by ``discard_snapshot`` (definitive commit when
+ # toggling Edit mode off) or ``restore_kwargs`` (Restore button).
+ self._saved_kwargs: dict[str, Any] | None = None
+
+ def snapshot_kwargs(self) -> None:
+ """Save a copy of the current kwargs as the pre-edit baseline.
+
+ No-op if a snapshot already exists (preserves the original baseline
+ across multiple edit-mode replays).
+ """
+ if self._saved_kwargs is None:
+ self._saved_kwargs = {
+ key: _copy_history_value(value) for key, value in self.kwargs.items()
+ }
+
+ def restore_kwargs(self) -> None:
+ """Restore kwargs from the saved snapshot and clear the snapshot."""
+ if self._saved_kwargs is not None:
+ self.kwargs = self._saved_kwargs
+ self._saved_kwargs = None
+
+ def discard_snapshot(self) -> None:
+ """Discard the saved snapshot (accept current kwargs as definitive)."""
+ self._saved_kwargs = None
+
+ @property
+ def has_pending_edits(self) -> bool:
+ """Return True if this action has unsaved edit-mode changes."""
+ return self._saved_kwargs is not None
+
+ def regenerate_uuid(self):
+ """Regenerate UUID after loading from a file (no-op: per-action UUID)."""
+
+ def copy(self, title_suffix: str | None = None) -> HistoryAction:
+ """Return an independent copy of this history action."""
+ state = self.state.copy()
+ title = self.title
+ if title_suffix:
+ title = f"{title} {title_suffix}"
+ new_action = HistoryAction(
+ title=title,
+ kind=self.kind,
+ panel_str=self.panel_str,
+ func_name=self.func_name,
+ pattern=self.pattern,
+ target=self.target,
+ method_name=self.method_name,
+ kwargs={
+ key: _copy_history_value(value) for key, value in self.kwargs.items()
+ },
+ state=state,
+ )
+ new_action.output_uuids = list(self.output_uuids)
+ # Note: _saved_kwargs is intentionally NOT propagated to the copy.
+ # Copying an action acts as an implicit commit (no pending edits).
+ return new_action
+
+ def copy_with_uuid_remap(
+ self, uuid_remap: dict[str, dict[str, str]]
+ ) -> HistoryAction:
+ """Return a copy of this action with all captured UUIDs rewritten.
+
+ Args:
+ uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}``
+ used to translate captured UUIDs to the cloned objects created by
+ the Duplicate operation.
+
+ Returns:
+ A new independent :class:`HistoryAction` with remapped UUIDs.
+ """
+ new_action = self.copy()
+ # Rewrite state.selection
+ for pstr, uuids in new_action.state.selection.items():
+ pmap = uuid_remap.get(pstr, {})
+ new_action.state.selection[pstr] = [pmap.get(u, u) for u in uuids]
+ # Rewrite state.object_metadata keys
+ for pstr, metadata in new_action.state.object_metadata.items():
+ pmap = uuid_remap.get(pstr, {})
+ new_action.state.object_metadata[pstr] = {
+ pmap.get(uuid, uuid): val for uuid, val in metadata.items()
+ }
+ # Rewrite obj2_uuids in kwargs
+ obj2 = new_action.kwargs.get("obj2_uuids")
+ if obj2:
+ if isinstance(obj2, str):
+ obj2 = [obj2]
+ pstr = new_action.panel_str or ""
+ pmap = uuid_remap.get(pstr, {})
+ rewritten = [pmap.get(u, u) for u in obj2]
+ new_action.kwargs["obj2_uuids"] = (
+ rewritten[0] if len(rewritten) == 1 else rewritten
+ )
+ # Rewrite output_uuids — they reference the target panel.
+ if new_action.output_uuids:
+ pstr = new_action.panel_str or ""
+ pmap = uuid_remap.get(pstr, {})
+ new_action.output_uuids = [pmap.get(u, u) for u in new_action.output_uuids]
+ return new_action
+
+ @property
+ def title(self) -> str:
+ """Return object title"""
+ return self.__title
+
+ # ------------------------------------------------------------------
+ # Description rendering (used by the tree view)
+ # ------------------------------------------------------------------
+
+ def __iter_param_kwargs(self) -> Generator[Any, None, None]:
+ """Yield kwargs values whose name ends with ``param`` (typically DataSets)."""
+ for kwname, value in self.kwargs.items():
+ if kwname.endswith("param") and value is not None:
+ yield value
+
+ @property
+ def description(self) -> str:
+ """Return object description (string representing function parameters)"""
+ desc = ""
+ no_parameters = True
+ for param in self.__iter_param_kwargs():
+ if desc:
+ desc += os.linesep
+ desc += str(param)
+ no_parameters = False
+ if desc or no_parameters:
+ if desc:
+ return desc
+ # Fall back to a textual hint of the resolved callable
+ return self.__fallback_doc()
+
+ def __fallback_doc(self) -> str:
+ """Return a single-line docstring for the underlying call, if available."""
+ try:
+ func = self._resolve_callable()
+ except (
+ ImportError,
+ ModuleNotFoundError,
+ AttributeError,
+ TypeError,
+ ValueError,
+ ):
+ return ""
+ doc = getattr(func, "__doc__", None) or ""
+ return doc.splitlines()[0] if doc else ""
+
+ @property
+ def description_summary(self) -> str:
+ """Return a short, single-line summary of the description (collapsed view).
+
+ For DataSet parameters, uses the dataset title followed by a compact
+ representation of its public fields ("name=value, ..."). Falls back to
+ the first non-empty line of the full description when no DataSet is
+ present.
+ """
+ summaries: list[str] = []
+ for param in self.__iter_param_kwargs():
+ if isinstance(param, DataSet):
+ title = param.get_title() or ""
+ # Collect "name=value" for each non-private item of the DataSet.
+ pairs: list[str] = []
+ for item in param.get_items():
+ name = item.get_name()
+ if name.startswith("_"):
+ continue
+ try:
+ value = item.get_value(param)
+ except (AttributeError, KeyError, TypeError, ValueError):
+ continue
+ # Format floats compactly, leave other reprs as-is
+ if isinstance(value, float):
+ value_str = f"{value:g}"
+ else:
+ value_str = str(value)
+ pairs.append(f"{name}={value_str}")
+ if pairs:
+ summaries.append(
+ f"{title}: {', '.join(pairs)}" if title else ", ".join(pairs)
+ )
+ elif title:
+ summaries.append(title)
+ if summaries:
+ return " | ".join(summaries)
+ for line in self.description.splitlines():
+ stripped = line.strip()
+ if stripped:
+ return stripped
+ return ""
+
+ @property
+ def description_html(self) -> str:
+ """Return rich-text (HTML) description used for the expanded view."""
+ # Normal path
+ parts: list[str] = []
+ no_parameters = True
+ for param in self.__iter_param_kwargs():
+ no_parameters = False
+ if isinstance(param, DataSet):
+ parts.append(param.to_html())
+ else:
+ parts.append(html.escape(str(param)).replace("\n", " "))
+ if parts:
+ return "
".join(parts)
+ if no_parameters:
+ text = self.description
+ if not text:
+ return ""
+ return html.escape(text).replace("\n", " ")
+ return ""
+
+ def to_macro_code(
+ self,
+ step_index: int,
+ input_var: str,
+ imports: set[str],
+ obj2_var: str | None = None,
+ ) -> tuple[list[str], str | None]:
+ """Return Python source lines for this action as a standalone sigima call.
+
+ Args:
+ step_index: Step number for variable naming.
+ input_var: Name of the input variable from the previous step.
+ imports: Mutable set of import statements accumulated by the caller.
+ obj2_var: Resolved variable name for the second operand (2-to-1
+ pattern). When ``None``, the second operand is left as a
+ placeholder.
+
+ Returns:
+ Tuple of (code_lines, output_var_name). ``output_var_name`` is
+ ``None`` for UI-kind actions (no data output).
+ """
+ if self.kind != self.KIND_COMPUTE:
+ return [f"# (UI) {self.title} [skipped]"], None
+
+ lines: list[str] = []
+ output_var = f"result_{step_index}"
+
+ # Determine the sigima module alias
+ if self.panel_str == "signal":
+ mod_alias = "sips"
+ imports.add("import sigima.proc.signal as sips")
+ elif self.panel_str == "image":
+ mod_alias = "sipi"
+ imports.add("import sigima.proc.image as sipi")
+ else:
+ lines.append(f"# {self.title} [unknown panel: {self.panel_str}]")
+ return lines, None
+
+ lines.append(f"# Step {step_index}: {self.title}")
+
+ param = self.kwargs.get("param")
+ param_var: str | None = None
+
+ if param is not None and isinstance(param, DataSet):
+ param_var = f"param_{step_index}"
+ param_class = type(param).__qualname__
+ param_module = type(param).__module__
+ imports.add(f"from {param_module} import {param_class}")
+ lines.append(f"{param_var} = {param_class}()")
+ # Reconstruct each attribute
+ for item in param.get_items():
+ attr_name = item.get_name()
+ value = getattr(param, attr_name, None)
+ if value is not None:
+ lines.append(f"{param_var}.{attr_name} = {value!r}")
+
+ # Build the function call
+ func_call = f"{mod_alias}.{self.func_name}"
+ if self.pattern in ("1_to_1", "1_to_0"):
+ if param_var:
+ lines.append(f"{output_var} = {func_call}({input_var}, {param_var})")
+ else:
+ lines.append(f"{output_var} = {func_call}({input_var})")
+ elif self.pattern == "n_to_1":
+ if param_var:
+ lines.append(f"{output_var} = {func_call}([{input_var}], {param_var})")
+ else:
+ lines.append(f"{output_var} = {func_call}([{input_var}])")
+ elif self.pattern == "2_to_1":
+ second = obj2_var or "... # TODO: provide second operand"
+ if param_var:
+ lines.append(
+ f"{output_var} = {func_call}({input_var}, {second}, {param_var})"
+ )
+ else:
+ lines.append(f"{output_var} = {func_call}({input_var}, {second})")
+ elif self.pattern == "1_to_n":
+ lines.append(f"{output_var} = {func_call}({input_var})")
+ else:
+ lines.append(f"# Unknown pattern {self.pattern!r}")
+ return lines, None
+
+ return lines, output_var
+
+ # ------------------------------------------------------------------
+ # Workspace-state delegation
+ # ------------------------------------------------------------------
+
+ def is_current_state_compatible(
+ self, mainwindow: DLMainWindow, restore_selection: bool
+ ) -> bool:
+ """Check if the current workspace state is compatible with the saved state."""
+ return self.state.is_current_state_compatible(mainwindow, restore_selection)
+
+ def restore(self, mainwindow: DLMainWindow) -> None:
+ """Restore the associated workspace state."""
+ self.state.restore(mainwindow)
+
+ # ------------------------------------------------------------------
+ # Replay
+ # ------------------------------------------------------------------
+
+ def _resolve_target(self, mainwindow: DLMainWindow) -> Any:
+ """Resolve the target object (UI kind) from the mainwindow."""
+ attr = self.target or "mainwindow"
+ if attr == "mainwindow":
+ return mainwindow
+ return getattr(mainwindow, attr)
+
+ def _resolve_panel(self, mainwindow: DLMainWindow):
+ """Resolve the data panel for a compute action."""
+ if self.panel_str == "signal":
+ return mainwindow.signalpanel
+ if self.panel_str == "image":
+ return mainwindow.imagepanel
+ raise ValueError(
+ f"Unknown panel_str {self.panel_str!r} for compute history action"
+ )
+
+ def _resolve_callable(self) -> Callable | None:
+ """Best-effort lookup of the underlying callable, for description only."""
+ if self.kind == self.KIND_COMPUTE and self.func_name:
+ for module in (sigima.proc.signal, sigima.proc.image):
+ func = getattr(module, self.func_name, None)
+ if callable(func):
+ return func
+ return None
+
+ def _resolve_obj_by_uuid(self, mainwindow: DLMainWindow, uuid: str) -> Any | None:
+ """Look up an object by UUID across both data panels."""
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ try:
+ return panel.objmodel[uuid]
+ except KeyError:
+ continue
+ return None
+
+ def replay(
+ self,
+ mainwindow: DLMainWindow,
+ restore_selection: bool,
+ edit: bool,
+ uuid_remap: dict[str, dict[str, str]] | None = None,
+ ) -> None:
+ """Replay the action.
+
+ Args:
+ mainwindow: DataLab's main window
+ restore_selection: True to restore the workspace selection before replaying
+ a UI-kind action. Ignored for compute-kind actions: their semantics
+ depends on which objects are selected (e.g. ``n_to_1`` aggregators
+ such as ``average`` require their captured multi-object selection),
+ so the captured selection is always restored before running the
+ computation.
+ edit: if True, always open the dialog boxes to edit parameters; if False,
+ use the parameters captured when the action was recorded
+ uuid_remap: optional per-panel mapping ``{panel_str: {old_uuid: new_uuid}}``
+ used during full-session replay to translate captured UUIDs to the
+ freshly-created ones. Defaults to an empty (identity) mapping.
+ """
+ if uuid_remap is None:
+ uuid_remap = {}
+ # Suppress history capture during replay to avoid recording
+ # synthetic entries when the processor re-executes features.
+ # The context manager is reentrant, so nesting with
+ # HistoryPanel.replay_restore_actions() is safe.
+ hpanel = getattr(mainwindow, "historypanel", None)
+ if hpanel is not None:
+ ctx = hpanel.replaying()
+ else:
+ ctx = nullcontext()
+ with ctx:
+ self._replay_inner(mainwindow, restore_selection, edit, uuid_remap)
+
+ def _replay_inner(
+ self,
+ mainwindow: DLMainWindow,
+ restore_selection: bool,
+ edit: bool,
+ uuid_remap: dict[str, dict[str, str]],
+ ) -> None:
+ """Inner replay logic, always called under the replaying guard."""
+ if self.kind == self.KIND_COMPUTE:
+ # Compute actions are selection-driven: restore the captured
+ # selection (translated through ``uuid_remap`` for session
+ # replays) whenever it is still resolvable so chained replays
+ # (especially ``n_to_1`` / ``2_to_1`` / ``1_to_n`` patterns)
+ # operate on the original input objects rather than on whatever
+ # the previous action left selected. When the captured UUIDs no
+ # longer exist (e.g. heuristic remap missed an object), fall
+ # back to the current selection -- replay may still fail
+ # downstream, but with the native processor error rather than
+ # an opaque ``WorkspaceState`` incompatibility.
+ translated = self._translate_state(uuid_remap)
+ if translated.is_current_state_compatible(mainwindow, False):
+ translated.restore(mainwindow)
+ self.replay_compute(mainwindow, edit, uuid_remap)
+ else:
+ if restore_selection:
+ self.state.restore(mainwindow)
+ self._replay_ui(mainwindow, edit)
+
+ def _translate_state(self, uuid_remap: dict[str, dict[str, str]]) -> WorkspaceState:
+ """Return a copy of ``self.state`` whose captured UUIDs have been
+ translated through ``uuid_remap`` (identity when no mapping)."""
+ if not uuid_remap:
+ return self.state
+ translated = WorkspaceState()
+ for panel_str, uuids in self.state.selection.items():
+ panel_map = uuid_remap.get(panel_str, {})
+ translated.selection[panel_str] = [panel_map.get(u, u) for u in uuids]
+ translated.states = dict(self.state.states)
+ translated.titles = dict(self.state.titles)
+ for panel_str, metadata in self.state.object_metadata.items():
+ panel_map = uuid_remap.get(panel_str, {})
+ translated.object_metadata[panel_str] = {
+ panel_map.get(uuid, uuid): dict(signature)
+ for uuid, signature in metadata.items()
+ }
+ return translated
+
+ def replay_compute(
+ self,
+ mainwindow: DLMainWindow,
+ edit: bool,
+ uuid_remap: dict[str, dict[str, str]] | None = None,
+ ) -> None:
+ """Replay a compute-kind action via ``processor.run_feature``."""
+ if self.pattern == "multiple_1_to_1":
+ raise NotImplementedError(
+ _("Replaying compound 'multiple_1_to_1' actions is not supported yet.")
+ )
+ panel = self._resolve_panel(mainwindow)
+ processor = panel.processor
+ feature = processor.get_feature(self.func_name)
+ run_kwargs: dict[str, Any] = {self.FUNC_EDIT_MODE: edit}
+
+ param = self.kwargs.get("param")
+ if self.pattern in {"1_to_1", "1_to_0", "n_to_1"}:
+ if param is not None:
+ run_kwargs["param"] = param
+ if self.pattern == "n_to_1" and "pairwise" in self.kwargs:
+ run_kwargs["pairwise"] = self.kwargs["pairwise"]
+ elif self.pattern == "2_to_1":
+ uuids = self.kwargs.get("obj2_uuids") or []
+ if isinstance(uuids, str):
+ uuids = [uuids]
+ # Translate captured UUIDs through ``uuid_remap`` (session replay).
+ # ``uuid_remap`` keys are ``panel.PANEL_STR_ID`` (matches
+ # ``WorkspaceState.selection`` keys and
+ # ``HistoryAction.panel_str``).
+ panel_map = (uuid_remap or {}).get(panel.PANEL_STR_ID, {})
+ uuids = [panel_map.get(u, u) for u in uuids]
+ objs2 = [
+ obj
+ for obj in (self._resolve_obj_by_uuid(mainwindow, u) for u in uuids)
+ if obj is not None
+ ]
+ if not objs2:
+ raise ValueError(
+ _("Cannot replay 2-to-1 action: source object(s) missing.")
+ )
+ run_kwargs["obj2"] = objs2[0] if len(objs2) == 1 else objs2
+ if param is not None:
+ run_kwargs["param"] = param
+ if "pairwise" in self.kwargs:
+ run_kwargs["pairwise"] = self.kwargs["pairwise"]
+ elif self.pattern == "1_to_n":
+ params = self.kwargs.get("params") or []
+ run_kwargs["params"] = params
+ else:
+ raise ValueError(f"Unknown compute pattern: {self.pattern!r}")
+ processor.run_feature(feature, **run_kwargs)
+
+ def _replay_ui(self, mainwindow: DLMainWindow, edit: bool) -> None:
+ """Replay a UI-kind action by calling ``target.method_name(**kwargs)``."""
+ hpanel = mainwindow.historypanel
+ if (
+ hpanel is not None
+ and hpanel.is_output_suppressed()
+ and self.method_name in self.UI_CREATION_METHODS
+ ):
+ return # Skip creation UI during non-persistent replay
+ target = self._resolve_target(mainwindow)
+ # Safety guard for destructive UI actions: if the action would delete
+ # objects but the captured selection no longer resolves to existing
+ # UUIDs in the target panel, skip the call rather than delete whatever
+ # is currently selected (which would silently destroy unrelated data).
+ if self.method_name in self.DESTRUCTIVE_METHODS:
+ if target is None:
+ _logger.warning(
+ "Skipping destructive replay '%s': target '%s' not found",
+ self.method_name,
+ self.target,
+ )
+ return
+ panel_str = getattr(target, "PANEL_STR_ID", None)
+ if panel_str and self.state and self.state.selection.get(panel_str):
+ existing_uuids = {
+ get_uuid(o)
+ for o in getattr(target, "objmodel", [])
+ if o is not None
+ }
+ captured = set(self.state.selection.get(panel_str, []))
+ if not captured & existing_uuids:
+ _logger.warning(
+ "Skipping destructive replay '%s': none of the captured "
+ "UUIDs %s exist in panel '%s' anymore",
+ self.method_name,
+ list(captured),
+ panel_str,
+ )
+ return
+ method = getattr(target, self.method_name)
+ call_kwargs = dict(self.kwargs)
+ # Inject edit mode if the method supports it
+ try:
+ sig = inspect.signature(method)
+ if self.FUNC_EDIT_MODE in sig.parameters:
+ call_kwargs[self.FUNC_EDIT_MODE] = edit
+ except (TypeError, ValueError):
+ pass
+ method(**call_kwargs)
+
+ # ------------------------------------------------------------------
+ # Serialisation -- no Callable is ever pickled
+ # ------------------------------------------------------------------
+
+ def serialize(self, writer: NativeH5Writer) -> None:
+ """Serialize this action."""
+ with writer.group("schema_version"):
+ writer.write(self.schema_version)
+ with writer.group("kind"):
+ writer.write(self.kind)
+ with writer.group("title"):
+ writer.write(self.__title)
+ if self.panel_str is not None:
+ with writer.group("panel_str"):
+ writer.write(self.panel_str)
+ if self.func_name is not None:
+ with writer.group("func_name"):
+ writer.write(self.func_name)
+ if self.pattern is not None:
+ with writer.group("pattern"):
+ writer.write(self.pattern)
+ if self.target is not None:
+ with writer.group("target"):
+ writer.write(self.target)
+ if self.method_name is not None:
+ with writer.group("method_name"):
+ writer.write(self.method_name)
+ encoded = _encode_kwargs(self.kwargs)
+ if encoded:
+ with writer.group("kwargs"):
+ writer.write_dict(encoded)
+ # ``saved_kwargs``: persisted Edit mode snapshot so the Restore button
+ # keeps working after save/reload. Group omitted when there are no
+ # pending edits.
+ if self._saved_kwargs is not None:
+ encoded_saved = _encode_kwargs(self._saved_kwargs)
+ # Write the group unconditionally (even when empty) so that the
+ # round-trip preserves the distinction between None (no pending
+ # edits) and {} (degenerate empty snapshot, keeps has_pending_edits).
+ with writer.group("saved_kwargs"):
+ writer.write_dict(encoded_saved)
+ # Only emit ``output_uuids`` when non-empty (empty lists skipped to
+ # avoid h5py edge cases with empty arrays).
+ if self.output_uuids:
+ with writer.group("output_uuids"):
+ writer.write(list(self.output_uuids))
+ # ``plugin_origin``: stored as a JSON string so the HDF5 schema stays
+ # trivially round-trippable. Skipped when None.
+ if self.plugin_origin is not None:
+ with writer.group("plugin_origin"):
+ writer.write(json.dumps(self.plugin_origin))
+ with writer.group("state"):
+ self.state.serialize(writer)
+ with writer.group("dtstr"):
+ writer.write(self.dtstr)
+
+ def deserialize(self, reader: NativeH5Reader) -> None:
+ """Deserialize this action."""
+ self.schema_version = reader.read(
+ "schema_version", default=HISTORY_SCHEMA_VERSION
+ )
+ with reader.group("kind"):
+ self.kind = reader.read_any()
+ with reader.group("title"):
+ self.__title = reader.read_any()
+ # Optional descriptors are written conditionally; check existence in
+ # the underlying HDF5 group before reading to avoid leaking ``__seq``
+ # frames on the option stack via guidata's read_any fallback path.
+ current = reader.h5
+ for option in reader.option:
+ current = current.require_group(option)
+ for attr in ("panel_str", "func_name", "pattern", "target", "method_name"):
+ if attr in current.attrs or attr in current:
+ with reader.group(attr):
+ setattr(self, attr, reader.read_any())
+ else:
+ setattr(self, attr, None)
+ if "kwargs" in current.attrs or "kwargs" in current:
+ with reader.group("kwargs"):
+ raw = reader.read_dict()
+ self.kwargs = _decode_kwargs(raw)
+ else:
+ self.kwargs = {}
+ # ``saved_kwargs`` group is present only when an Edit mode snapshot
+ # exists; otherwise leave it as ``None``.
+ if "saved_kwargs" in current.attrs or "saved_kwargs" in current:
+ with reader.group("saved_kwargs"):
+ raw_saved = reader.read_dict()
+ self._saved_kwargs = _decode_kwargs(raw_saved)
+ else:
+ self._saved_kwargs = None
+ # ``output_uuids`` is present only when the action produced outputs;
+ # otherwise leave it empty and consumers fall back to the heuristic
+ # matcher.
+ if "output_uuids" in current.attrs or "output_uuids" in current:
+ with reader.group("output_uuids"):
+ raw_outputs = reader.read_any()
+ if raw_outputs is None:
+ self.output_uuids = []
+ else:
+ self.output_uuids = [str(u) for u in raw_outputs]
+ else:
+ self.output_uuids = []
+ # ``plugin_origin`` is present only for plugin-originated compute
+ # actions; otherwise leave it as ``None`` (a replay of a missing plugin
+ # function then surfaces a generic ``FeatureNotFoundError``).
+ if "plugin_origin" in current.attrs or "plugin_origin" in current:
+ with reader.group("plugin_origin"):
+ raw_origin = reader.read_any()
+ if raw_origin in (None, ""):
+ self.plugin_origin = None
+ else:
+ try:
+ self.plugin_origin = json.loads(raw_origin)
+ except (TypeError, ValueError):
+ _logger.warning(
+ "Failed to decode plugin_origin for action %s; "
+ "falling back to None.",
+ self.uuid,
+ )
+ self.plugin_origin = None
+ else:
+ self.plugin_origin = None
+ with reader.group("state"):
+ self.state.deserialize(reader)
+ with reader.group("dtstr"):
+ self.dtstr = reader.read_any()
diff --git a/datalab/history/core.py b/datalab/history/core.py
new file mode 100644
index 00000000..07ec11a4
--- /dev/null
+++ b/datalab/history/core.py
@@ -0,0 +1,237 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+History core utilities: schema constants, JSON codec, ``@add_to_history`` decorator.
+"""
+
+from __future__ import annotations
+
+import functools
+import importlib
+import json
+import logging
+import warnings
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+from guidata.dataset.conv import dataset_to_json, json_to_dataset
+from guidata.dataset.datatypes import DataSet
+from qtpy import QtCore as QC
+from sigima.objects.base import BaseROI
+
+from datalab.config import _
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.gui.panel.history import HistoryPanel
+ from datalab.gui.processor.base import BaseProcessor
+
+_logger = logging.getLogger(__name__)
+_TRUSTED_ROI_MODULE_PREFIX = "sigima."
+
+# Schema versions for persisted history sessions/actions. Both start at 1.
+# Bump the relevant constant (and add the corresponding optional field
+# handling in serialize/deserialize) when the on-disk layout evolves.
+HISTORY_SCHEMA_VERSION = 1
+HISTORY_ACTION_SCHEMA_VERSION = 1
+# Keys used in the kwargs dict to mark DataSet payloads, so that the
+# serialization layer can round-trip them as JSON strings instead of pickling
+# arbitrary Python objects.
+_DATASET_MARKER = "__dataset_json__"
+_DATASET_LIST_MARKER = "__dataset_list_json__"
+_ROI_MARKER = "__roi_json__"
+
+
+def get_datetime_str() -> str:
+ """Return current date and time as a string"""
+ return QC.QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss")
+
+
+def _numpy_to_json_safe(obj: Any) -> Any:
+ """Recursively convert numpy arrays to lists for JSON serialization."""
+ if isinstance(obj, np.ndarray):
+ return obj.tolist()
+ if isinstance(obj, dict):
+ return {k: _numpy_to_json_safe(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [_numpy_to_json_safe(i) for i in obj]
+ return obj
+
+
+def _encode_roi(roi: Any) -> str:
+ """Encode a sigima ROI object to a JSON string via ``to_dict()``."""
+ if not isinstance(roi, BaseROI):
+ raise TypeError(f"Expected BaseROI instance, got {type(roi)!r}")
+ roi_dict = _numpy_to_json_safe(roi.to_dict())
+ # Store the concrete class so we can reconstruct on decode.
+ payload = {
+ "module": type(roi).__module__,
+ "class": type(roi).__qualname__,
+ "data": roi_dict,
+ }
+ return json.dumps(payload)
+
+
+def _decode_roi(encoded: str) -> Any:
+ """Decode a JSON string back to a sigima ROI object.
+
+ Only classes from trusted ``sigima.`` modules that are actual
+ :class:`sigima.objects.base.BaseROI` subclasses are allowed.
+
+ Raises:
+ ValueError: If the module is not a trusted sigima module or the
+ resolved class is not a BaseROI subclass.
+ """
+ payload = json.loads(encoded)
+ module_name = payload["module"]
+ class_name = payload["class"]
+
+ if not module_name.startswith(_TRUSTED_ROI_MODULE_PREFIX):
+ raise ValueError(
+ f"Untrusted ROI module {module_name!r}: "
+ f"only modules under {_TRUSTED_ROI_MODULE_PREFIX!r} are allowed"
+ )
+
+ mod = importlib.import_module(module_name)
+ cls = getattr(mod, class_name)
+
+ if not (isinstance(cls, type) and issubclass(cls, BaseROI)):
+ raise ValueError(f"{module_name}.{class_name} is not a BaseROI subclass")
+
+ return cls.from_dict(payload["data"])
+
+
+def _encode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
+ """Encode kwargs for HDF5 storage: replace ``DataSet``, ``list[DataSet]``,
+ and sigima ROI values with marker dicts holding their JSON representation.
+
+ All other values must already be HDF5-friendly primitives (str, int, float,
+ bool, list/tuple of the same).
+
+ Args:
+ kwargs: Raw kwargs dict (may contain ``DataSet`` or ROI instances).
+
+ Returns:
+ A new dict with special values wrapped in marker dicts.
+ """
+ encoded: dict[str, Any] = {}
+ for key, value in kwargs.items():
+ if value is None:
+ continue
+ if isinstance(value, DataSet):
+ encoded[key] = {_DATASET_MARKER: dataset_to_json(value)}
+ elif isinstance(value, BaseROI):
+ encoded[key] = {_ROI_MARKER: _encode_roi(value)}
+ elif (
+ isinstance(value, list)
+ and value
+ and all(isinstance(item, DataSet) for item in value)
+ ):
+ encoded[key] = {
+ _DATASET_LIST_MARKER: [dataset_to_json(item) for item in value]
+ }
+ else:
+ encoded[key] = value
+ return encoded
+
+
+def _decode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
+ """Inverse of :func:`_encode_kwargs`."""
+ decoded: dict[str, Any] = {}
+ for key, value in kwargs.items():
+ if isinstance(value, dict) and _DATASET_MARKER in value:
+ try:
+ decoded[key] = json_to_dataset(value[_DATASET_MARKER])
+ except (TypeError, ValueError, KeyError):
+ warnings.warn(
+ _("Failed to deserialize history DataSet kwarg %r.") % key
+ )
+ decoded[key] = None
+ elif isinstance(value, dict) and _ROI_MARKER in value:
+ try:
+ decoded[key] = _decode_roi(value[_ROI_MARKER])
+ except Exception as exc:
+ raise ValueError(
+ f"Failed to deserialize history ROI kwarg {key!r}: {exc}"
+ ) from exc
+ elif isinstance(value, dict) and _DATASET_LIST_MARKER in value:
+ try:
+ decoded[key] = [
+ json_to_dataset(item) for item in value[_DATASET_LIST_MARKER]
+ ]
+ except (TypeError, ValueError, KeyError):
+ warnings.warn(
+ _("Failed to deserialize history DataSet-list kwarg %r.") % key
+ )
+ decoded[key] = []
+ else:
+ decoded[key] = value
+ return decoded
+
+
+def _copy_history_value(value: Any) -> Any:
+ """Return an independent copy of a history-serializable value."""
+ if callable(value):
+ raise TypeError("History duplication does not support callable kwargs")
+ if isinstance(value, DataSet):
+ return json_to_dataset(dataset_to_json(value))
+ if isinstance(value, BaseROI):
+ return _decode_roi(_encode_roi(value))
+ if isinstance(value, dict):
+ return {key: _copy_history_value(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [_copy_history_value(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(_copy_history_value(item) for item in value)
+ return deepcopy(value)
+
+
+def add_to_history(kwargs_names: list[str] | None = None, title: str | None = None):
+ """Method decorator to add the method call to the history panel as a UI entry.
+
+ Args:
+ kwargs_names: List of keyword arguments to add to the history action.
+ Defaults to None.
+ title: Title of the history action. Defaults to None.
+ """
+ if kwargs_names is None:
+ kwargs_names = []
+
+ def add_to_history_decorator(func):
+ """Decorator function"""
+
+ @functools.wraps(func)
+ def method_wrapper(*args, **kwargs):
+ """Decorator wrapper function"""
+ self: BaseDataPanel | BaseProcessor = args[0]
+ history: HistoryPanel = self.mainwindow.historypanel
+ histkwargs = {k: kwargs[k] for k in kwargs_names if k in kwargs}
+ target = _resolve_self_target(self)
+ if target is not None:
+ history.add_ui_entry(
+ kwargs.get("title", title) or func.__name__,
+ target=target,
+ method_name=func.__name__,
+ save_state=kwargs.get("save_state", True),
+ **histkwargs,
+ )
+ return func(*args, **kwargs)
+
+ return method_wrapper
+
+ return add_to_history_decorator
+
+
+def _resolve_self_target(self_obj: Any) -> str | None:
+ """Resolve a 'self' instance to a string target understood by replay.
+
+ Used by the legacy ``@add_to_history`` decorator. Returns None when no
+ safe routing is possible (in which case the entry is skipped).
+ """
+ panel_str = getattr(self_obj, "PANEL_STR_ID", None)
+ if panel_str == "signal":
+ return "signalpanel"
+ if panel_str == "image":
+ return "imagepanel"
+ return None
diff --git a/datalab/history/session.py b/datalab/history/session.py
new file mode 100644
index 00000000..7566ed9e
--- /dev/null
+++ b/datalab/history/session.py
@@ -0,0 +1,413 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""HistorySession: ordered list of HistoryAction with replay logic."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from datalab.config import _
+from datalab.history.action import HistoryAction
+from datalab.history.core import HISTORY_SCHEMA_VERSION, get_datetime_str
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+ from datalab.h5.native import NativeH5Reader, NativeH5Writer
+
+
+class HistorySession:
+ """Object representing a history session, i.e. a list of actions.
+
+ A history session is a list of actions that can be replayed in the same order
+ as they were added to the history session. The history session can be saved to
+ a file and loaded from a file.
+
+ Args:
+ title: Title of the history session
+ number: Number of the history session
+ """
+
+ def __init__(self, title: str = "", number: int = 0) -> None:
+ """Create a new history session"""
+ prefix = _("Session")
+ self.title = title if title else f"{prefix} {number:03d}"
+ self.number = number
+ self.dtstr: str = get_datetime_str()
+ self.actions: list[HistoryAction] = []
+ self.schema_version: int = HISTORY_SCHEMA_VERSION
+
+ def add_action(self, action: HistoryAction) -> None:
+ """Add an action to the history session
+
+ Args:
+ action: Action to add
+ """
+ self.actions.append(action)
+
+ def copy(
+ self, title: str | None = None, action_title_suffix: str | None = None
+ ) -> HistorySession:
+ """Return an independent copy of this history session."""
+ session = HistorySession(title=title or self.title, number=self.number)
+ session.actions = [
+ action.copy(title_suffix=action_title_suffix) for action in self.actions
+ ]
+ return session
+
+ def copy_with_uuid_remap(
+ self, title: str, uuid_remap: dict[str, dict[str, str]]
+ ) -> HistorySession:
+ """Return a copy of this session with all UUIDs rewritten via ``uuid_remap``.
+
+ Used by the Duplicate operation to build an independent session whose
+ captured object references point to the cloned data objects.
+
+ Args:
+ title: Title for the new session.
+ uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}``.
+
+ Returns:
+ A new :class:`HistorySession` with all captured UUIDs remapped.
+ """
+ session = HistorySession(title=title, number=self.number)
+ session.actions = [
+ action.copy_with_uuid_remap(uuid_remap) for action in self.actions
+ ]
+ return session
+
+ def is_current_state_compatible(
+ self, mainwindow: DLMainWindow, restore_selection: bool
+ ) -> bool:
+ """Check if the current workspace state is compatible with the saved state
+
+ Args:
+ mainwindow: DataLab's main window
+ restore_selection: True to restore the selection before checking the state
+
+ Returns:
+ bool: True if the current workspace state is compatible with the saved state
+ """
+ if self.actions:
+ return self.actions[0].is_current_state_compatible(
+ mainwindow, restore_selection
+ )
+ return True
+
+ def restore(self, mainwindow: DLMainWindow) -> None:
+ """Restore the state of the workspace associated to the first action of session
+
+ Args:
+ mainwindow: DataLab's main window
+ """
+ if self.actions:
+ self.actions[0].restore(mainwindow)
+
+ def replay(
+ self, mainwindow: DLMainWindow, restore_selection: bool, edit: bool
+ ) -> None:
+ """Replay the history session
+
+ Args:
+ mainwindow: DataLab's main window
+ restore_selection: True to restore the workspace selection before replaying
+ edit: if True, always open the dialog boxes to edit parameters, if False,
+ use the parameters passed when creating the action
+ """
+ # Per-panel ``{old_uuid: new_uuid}`` mapping, populated as UI actions
+ # create new objects. Used by compute actions to translate their
+ # captured selection (and ``obj2_uuids``) into the freshly-created
+ # UUIDs of the current replay, so chained ``n_to_1`` / ``2_to_1`` /
+ # ``1_to_n`` actions operate on the correct inputs. Keys are
+ # ``panel.PANEL_STR_ID`` (matches ``WorkspaceState.selection`` keys).
+ panels = (mainwindow.signalpanel, mainwindow.imagepanel)
+ uuid_remap: dict[str, dict[str, str]] = {p.PANEL_STR_ID: {} for p in panels}
+ # FIFO of newly-created UUIDs not yet claimed by a remap entry --
+ # required because most creation UI actions (e.g. ``new_signal``)
+ # are recorded with ``save_state=False`` (empty captured selection),
+ # so we cannot pair captured-vs-new UUIDs by position at UI time.
+ # Subsequent compute actions claim from this queue on demand.
+ unclaimed: dict[str, list[str]] = {p.PANEL_STR_ID: [] for p in panels}
+
+ def _claim_unmapped(
+ pstr: str,
+ old_uuids: list[str],
+ action: HistoryAction,
+ ) -> None:
+ """Claim unclaimed new UUIDs for *old_uuids* not yet in uuid_remap.
+
+ Uses title matching (scanning the full unclaimed queue) followed by
+ panel-order index alignment to deterministically pair old UUIDs
+ to the correct new UUIDs, regardless of creation order.
+ """
+ # Collect unmapped UUIDs (deduplicated, preserving first-seen order).
+ all_unmapped: list[str] = []
+ seen: set[str] = set()
+ for u in old_uuids:
+ if u not in seen and u not in uuid_remap.get(pstr, {}):
+ all_unmapped.append(u)
+ seen.add(u)
+ if not all_unmapped:
+ return
+ # Re-sort by recorded panel position when available.
+ panel_order = list(action.state.object_metadata.get(pstr, {}).keys())
+ if panel_order and all(u in panel_order for u in all_unmapped):
+ all_unmapped.sort(key=panel_order.index)
+ queue = unclaimed.get(pstr) or []
+ if not queue:
+ return
+ # Build old UUID → title from captured state and object_metadata.
+ sel_uuids = action.state.selection.get(pstr, [])
+ sel_titles = action.state.titles.get(pstr, [])
+ old_titles: dict[str, str] = {}
+ for _u, _t in zip(sel_uuids, sel_titles):
+ if _u in seen:
+ old_titles[_u] = _t
+ obj_meta = action.state.object_metadata.get(pstr, {})
+ for _u in all_unmapped:
+ if _u not in old_titles and _u in obj_meta:
+ meta = obj_meta[_u]
+ if isinstance(meta, dict) and "title" in meta:
+ old_titles[_u] = meta["title"]
+ # Build new UUID → title from the live panel (full queue).
+ new_titles: dict[str, str] = {}
+ panel_obj = None
+ for p in panels:
+ if p.PANEL_STR_ID == pstr:
+ panel_obj = p
+ break
+ if panel_obj is not None:
+ for nu in queue:
+ try:
+ new_titles[nu] = panel_obj.objmodel[nu].title
+ except KeyError:
+ pass
+ # Phase 1: title matching against the FULL queue.
+ assigned_old: set[str] = set()
+ assigned_new: set[str] = set()
+ for ou in all_unmapped:
+ if ou not in old_titles:
+ continue
+ title = old_titles[ou]
+ candidates = [
+ nu
+ for nu in queue
+ if nu not in assigned_new and new_titles.get(nu) == title
+ ]
+ if len(candidates) == 1:
+ uuid_remap.setdefault(pstr, {})[ou] = candidates[0]
+ assigned_old.add(ou)
+ assigned_new.add(candidates[0])
+ # Phase 2: positional fallback using panel-order alignment.
+ # Two modes depending on whether the remaining queue covers all
+ # free recorded panel slots:
+ #
+ # A) Absolute index alignment (len(rem_queue) == len(free_indices)):
+ # Each free panel_order index maps 1-to-1 to a queue slot.
+ # This ensures e.g. the second-created object maps to the
+ # second queue entry even when only a subset of old UUIDs
+ # needs claiming.
+ #
+ # B) Relative order fallback (queue is a strict subset):
+ # The queue only contains later compute-created objects while
+ # earlier full-panel entries are absent. Absolute alignment
+ # would leave non-first old UUIDs unmapped. Instead, zip
+ # rem_old (already sorted by panel order) with rem_queue
+ # sequentially.
+ rem_old = [u for u in all_unmapped if u not in assigned_old]
+ if rem_old and panel_order:
+ rem_queue = [u for u in queue if u not in assigned_new]
+ # Find which panel_order indices are "free" (unclaimed).
+ free_indices: list[int] = []
+ for idx, po_uuid in enumerate(panel_order):
+ if po_uuid not in uuid_remap.get(pstr, {}):
+ if po_uuid not in assigned_old:
+ free_indices.append(idx)
+ if len(rem_queue) == len(free_indices):
+ # Mode A: absolute index alignment.
+ idx_to_new: dict[int, str] = {}
+ for qi, fi in enumerate(free_indices):
+ if qi < len(rem_queue):
+ idx_to_new[fi] = rem_queue[qi]
+ for ou in rem_old:
+ if ou in panel_order:
+ idx = panel_order.index(ou)
+ if idx in idx_to_new:
+ nu = idx_to_new[idx]
+ uuid_remap.setdefault(pstr, {})[ou] = nu
+ assigned_new.add(nu)
+ else:
+ # Mode B: relative order fallback.
+ for ou, nu in zip(rem_old, rem_queue):
+ uuid_remap.setdefault(pstr, {})[ou] = nu
+ assigned_new.add(nu)
+ elif rem_old:
+ # No panel_order available: sequential fallback.
+ rem_queue = [u for u in queue if u not in assigned_new]
+ for ou, nu in zip(rem_old, rem_queue):
+ uuid_remap.setdefault(pstr, {})[ou] = nu
+ assigned_new.add(nu)
+ # Remove all assigned new UUIDs from the unclaimed queue.
+ if assigned_new:
+ unclaimed[pstr] = [u for u in queue if u not in assigned_new]
+
+ for action in self.actions[:]:
+ before = {p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in panels}
+ if action.kind == HistoryAction.KIND_COMPUTE:
+ # Lazy-resolve any captured UUIDs missing from the remap by
+ # claiming from ``unclaimed`` (deterministic: title + panel-order).
+ pstr = action.panel_str or ""
+ captured = action.state.selection.get(pstr, [])
+ if action.pattern == "2_to_1":
+ # For 2_to_1: collect ALL unmapped old UUIDs from both
+ # captured selection and obj2_uuids in one batch so
+ # operand order is preserved by the helper.
+ obj2 = action.kwargs.get("obj2_uuids") or []
+ if isinstance(obj2, str):
+ obj2 = [obj2]
+ _claim_unmapped(pstr, list(obj2) + list(captured), action)
+ else:
+ # For all other compute patterns (1_to_1, n_to_1, etc.):
+ # use the same deterministic helper.
+ _claim_unmapped(pstr, list(captured), action)
+ action.replay(
+ mainwindow,
+ restore_selection=restore_selection,
+ edit=edit,
+ uuid_remap=uuid_remap,
+ )
+ # Post-action bookkeeping: track new/removed UUIDs for *every*
+ # action kind so that later actions consuming compute-created
+ # outputs can resolve them through ``uuid_remap`` / ``unclaimed``.
+ for panel in panels:
+ pstr = panel.PANEL_STR_ID
+ current_ids = set(panel.objmodel.get_object_ids())
+ new_uuids = [
+ u for u in panel.objmodel.get_object_ids() if u not in before[pstr]
+ ]
+ # Drop vanished UUIDs from the unclaimed queue and the
+ # reverse remap entries (e.g. ``Remove selected objects``):
+ # this keeps the FIFO claim in sync with the live panel
+ # contents during chained creation/removal replays.
+ removed_uuids = before[pstr] - current_ids
+ if removed_uuids:
+ unclaimed[pstr] = [
+ u for u in unclaimed.get(pstr, []) if u not in removed_uuids
+ ]
+ panel_map = uuid_remap.get(pstr, {})
+ for old_key in [
+ k for k, v in panel_map.items() if v in removed_uuids
+ ]:
+ panel_map.pop(old_key, None)
+ if not new_uuids:
+ continue
+ if action.kind == HistoryAction.KIND_UI:
+ captured = action.state.selection.get(pstr, [])
+ if captured:
+ # Captured post-action selection available: pair
+ # captured UUIDs with new UUIDs by position.
+ for old_uuid, new_uuid in zip(captured, new_uuids):
+ uuid_remap.setdefault(pstr, {})[old_uuid] = new_uuid
+ # Any extra newly-created UUIDs go to the queue.
+ unclaimed.setdefault(pstr, []).extend(
+ new_uuids[len(captured) :]
+ )
+ else:
+ # No captured selection (typical of ``new_signal``):
+ # queue all new UUIDs for lazy claiming.
+ unclaimed.setdefault(pstr, []).extend(new_uuids)
+ else:
+ # Compute actions: queue all newly-created UUIDs so
+ # later actions can lazily claim them. Do NOT map
+ # captured input UUIDs to output UUIDs — compute
+ # inputs and outputs are semantically different.
+ unclaimed.setdefault(pstr, []).extend(new_uuids)
+
+ # Visually close the replay: select the output of the last compute
+ # action so the user sees the final result highlighted in the panel.
+ # Without this, the very last action's output is never selected
+ # (intermediate actions are implicitly "closed" by the next
+ # iteration's input restore).
+ if self.actions:
+ select_last_compute_output(mainwindow, panels, uuid_remap, self.actions[-1])
+
+ def serialize(self, writer: NativeH5Writer) -> None:
+ """Serialize this history session
+
+ Args:
+ writer: Writer
+ """
+ with writer.group("schema_version"):
+ writer.write(self.schema_version)
+ with writer.group("title"):
+ writer.write(self.title)
+ with writer.group("number"):
+ writer.write(self.number)
+ with writer.group("dtstr"):
+ writer.write(self.dtstr)
+ writer.write_object_list(self.actions, "actions")
+
+ def deserialize(self, reader: NativeH5Reader) -> None:
+ """Deserialize this history session
+
+ Args:
+ reader: Reader
+ """
+ self.schema_version = reader.read(
+ "schema_version", default=HISTORY_SCHEMA_VERSION
+ )
+ with reader.group("title"):
+ self.title = reader.read_any()
+ with reader.group("number"):
+ self.number = reader.read_any()
+ with reader.group("dtstr"):
+ self.dtstr = reader.read_any()
+ self.actions = reader.read_object_list("actions", HistoryAction)
+
+ def remove_action(self, action: HistoryAction) -> None:
+ """Remove an action from the history session
+
+ This implies removing all subsequent actions. If action is not found, this
+ fails silently.
+
+ Args:
+ action: Action to remove
+ """
+ if action in self.actions:
+ index = self.actions.index(action)
+ self.actions = self.actions[:index]
+
+
+def select_last_compute_output(
+ mainwindow: DLMainWindow,
+ panels: tuple,
+ uuid_remap: dict[str, dict[str, str]],
+ last_action: HistoryAction,
+) -> None:
+ """Select the output of the last compute action after a session replay.
+
+ Visually closes the replay by highlighting the final result in its panel.
+ No-op when the last action is not a compute action or its output is gone.
+
+ Args:
+ mainwindow: DataLab's main window
+ panels: signal and image panels
+ uuid_remap: per-panel ``{old_uuid: new_uuid}`` mapping built during replay
+ last_action: last action of the replayed session
+ """
+ if last_action.kind != HistoryAction.KIND_COMPUTE:
+ return
+ hpanel = getattr(mainwindow, "historypanel", None)
+ if hpanel is None:
+ return
+ output_uuid = hpanel.action_output_uuid(last_action)
+ if not output_uuid:
+ return
+ panel_str = last_action.panel_str or ""
+ mapped_uuid = uuid_remap.get(panel_str, {}).get(output_uuid, output_uuid)
+ target_panel = next((p for p in panels if p.PANEL_STR_ID == panel_str), None)
+ if target_panel is None:
+ return
+ try:
+ target_panel.objview.select_objects([mapped_uuid])
+ except KeyError:
+ pass
diff --git a/datalab/history/workspace_state.py b/datalab/history/workspace_state.py
new file mode 100644
index 00000000..a665d6a8
--- /dev/null
+++ b/datalab/history/workspace_state.py
@@ -0,0 +1,249 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Workspace state snapshot captured at history action time."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any
+
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+ from datalab.h5.native import NativeH5Reader, NativeH5Writer
+
+
+class WorkspaceState:
+ """Object representing the workspace state at a given time.
+
+ The workspace state stores the per-panel selection of objects by **UUID**
+ (robust against reordering, renaming or interleaved insertions). For
+ informative display, it also retains the data shape and title of each
+ selected object at the time of capture.
+ """
+
+ def __init__(self) -> None:
+ """Create a new workspace state"""
+ # The selection is stored as a dictionary where the key is the panel name
+ # and the value is the list of UUIDs of selected objects.
+ self.selection: dict[str, list[str]] = {}
+ # The states are stored as a dictionary where the key is the panel name
+ # and the value is the list of states (str) of the objects in the panel. The
+ # state is a string containing the object data shape (kept for informative
+ # display only -- not used for selection matching anymore).
+ self.states: dict[str, list[str]] = {}
+ # The titles are stored as a dictionary where the key is the panel name and the
+ # value is the list of titles of the objects in the panel. The title is only
+ # informative and is not used to determine if two objects have the same state.
+ self.titles: dict[str, list[str]] = {}
+ # Structured data signatures of selected objects, keyed by panel name and UUID.
+ # This is the current schema used for compatibility checks. Missing metadata
+ # means a pre-Gate-2 history and falls back to UUID-existence validation.
+ self.object_metadata: dict[str, dict[str, dict[str, Any]]] = {}
+
+ def copy(self) -> WorkspaceState:
+ """Return an independent copy of this workspace state."""
+ state = WorkspaceState()
+ state.selection = deepcopy(self.selection)
+ state.states = deepcopy(self.states)
+ state.titles = deepcopy(self.titles)
+ state.object_metadata = deepcopy(self.object_metadata)
+ return state
+
+ def serialize(self, writer: NativeH5Writer) -> None:
+ """Serialize this workspace state
+
+ Args:
+ writer: Writer
+ """
+ with writer.group("selection"):
+ writer.write_dict(self.selection)
+ with writer.group("states"):
+ writer.write_dict(self.states)
+ with writer.group("titles"):
+ writer.write_dict(self.titles)
+ with writer.group("object_metadata"):
+ writer.write_dict(self.object_metadata)
+
+ def deserialize(self, reader: NativeH5Reader) -> None:
+ """Deserialize this workspace state
+
+ Args:
+ reader: Reader
+ """
+ with reader.group("selection"):
+ self.selection = reader.read_dict()
+ with reader.group("states"):
+ self.states = reader.read_dict()
+ with reader.group("titles"):
+ self.titles = reader.read_dict()
+ current = reader.h5
+ for option in reader.option:
+ current = current[option]
+ if "object_metadata" in current.attrs or "object_metadata" in current:
+ with reader.group("object_metadata"):
+ self.object_metadata = reader.read_dict()
+ else:
+ self.object_metadata = {}
+ # Normalize legacy translated keys to stable panel identifiers.
+ self.selection = self._normalize_panel_keys(self.selection)
+ self.states = self._normalize_panel_keys(self.states)
+ self.titles = self._normalize_panel_keys(self.titles)
+ self.object_metadata = self._normalize_panel_keys(self.object_metadata)
+
+ def get_current_selection(self, mainwindow: DLMainWindow) -> dict[str, list[str]]:
+ """Get the current selection in the workspace, keyed by panel name and
+ valued by the list of selected object UUIDs.
+
+ Args:
+ mainwindow: DataLab's main window
+
+ Returns:
+ Current selection in the workspace, by panel name → list of UUIDs.
+ """
+ selection: dict[str, list[str]] = {}
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ selection[panel.PANEL_STR_ID] = [
+ get_uuid(obj)
+ for obj in panel.objview.get_sel_objects(include_groups=True)
+ ]
+ return selection
+
+ @staticmethod
+ def get_object_metadata(obj: Any) -> dict[str, Any]:
+ """Return a stable data signature for an object."""
+ data = getattr(obj, "data", None)
+ shape = getattr(data, "shape", None)
+ if shape is None:
+ return {}
+ shape = [int(size) for size in shape]
+ ndim = getattr(data, "ndim", len(shape))
+ return {"shape": shape, "ndim": int(ndim)}
+
+ @staticmethod
+ def _normalize_object_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
+ """Normalize object metadata loaded from HDF5 for comparison."""
+ shape = metadata.get("shape")
+ if shape is None:
+ return {}
+ shape = [int(size) for size in shape]
+ ndim = metadata.get("ndim", len(shape))
+ return {"shape": shape, "ndim": int(ndim)}
+
+ # Mapping from legacy translated panel keys to stable identifiers.
+ # Covers the English translations; other locales are handled by the
+ # catch-all ``"signal"``/``"image"`` substring heuristic below.
+ _LEGACY_PANEL_KEY_MAP: dict[str, str] = {
+ "Signal Panel": "signal",
+ "Image Panel": "image",
+ }
+
+ @classmethod
+ def _normalize_panel_key(cls, key: str) -> str:
+ """Map a potentially translated panel key to its stable identifier."""
+ if key in ("signal", "image"):
+ return key
+ mapped = cls._LEGACY_PANEL_KEY_MAP.get(key)
+ if mapped is not None:
+ return mapped
+ # Heuristic for non-English translations: look for the stable ID
+ # substring in the key (e.g. "Panneau signal" → "signal").
+ lowered = key.lower()
+ for stable_id in ("signal", "image"):
+ if stable_id in lowered:
+ return stable_id
+ return key
+
+ @classmethod
+ def _normalize_panel_keys(cls, d: dict) -> dict:
+ """Return *d* with all top-level keys normalized to stable panel IDs."""
+ return {cls._normalize_panel_key(k): v for k, v in d.items()}
+
+ def save(self, mainwindow: DLMainWindow) -> None:
+ """Save the current workspace state
+
+ Args:
+ mainwindow: DataLab's main window
+ """
+ self.selection = self.get_current_selection(mainwindow)
+ self.object_metadata = {}
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ sel_uuids = self.selection[panel.PANEL_STR_ID]
+ self.states[panel.PANEL_STR_ID] = [
+ str(obj.data.shape)
+ for obj in panel.objmodel
+ if get_uuid(obj) in sel_uuids
+ ]
+ self.titles[panel.PANEL_STR_ID] = [
+ obj.title for obj in panel.objmodel if get_uuid(obj) in sel_uuids
+ ]
+ # Store metadata for ALL panel objects (not just selected) so that
+ # the dict key order captures the full panel ordering. During
+ # session replay the key order lets us sort old UUIDs by their
+ # original panel position, which prevents non-commutative 2_to_1
+ # operand swaps in the positional-fallback code path.
+ # ``is_current_state_compatible`` only checks *selected* UUIDs, so
+ # the extra entries are harmless for compatibility validation.
+ self.object_metadata[panel.PANEL_STR_ID] = {
+ get_uuid(obj): self.get_object_metadata(obj) for obj in panel.objmodel
+ }
+
+ def is_current_state_compatible( # pylint: disable=unused-argument
+ self, mainwindow: DLMainWindow, restore_selection: bool
+ ) -> bool:
+ """Check if the current workspace state is compatible with the saved state.
+
+ Compatibility means that **every** UUID recorded in the saved selection
+ still exists in the corresponding panel. When structured object metadata
+ is available (current schema), each selected object's data shape and
+ dimensions must also match the saved signature. Histories without this
+ metadata fall back to legacy UUID-existence validation.
+
+ Args:
+ mainwindow: DataLab's main window
+ restore_selection: Unused (kept for API symmetry). With UUID-based
+ identity, the compatibility check no longer depends on the current
+ selection -- it only depends on object existence.
+
+ Returns:
+ True if every saved UUID still exists in its panel and saved
+ metadata, when available, still matches.
+ """
+ if not self.selection:
+ return True
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ saved_uuids = self.selection.get(panel.PANEL_STR_ID, [])
+ existing_uuids = set(panel.objmodel.get_object_ids())
+ saved_metadata = self.object_metadata.get(panel.PANEL_STR_ID, {})
+ for uuid in saved_uuids:
+ if uuid not in existing_uuids:
+ return False
+ if uuid in saved_metadata:
+ current = self.get_object_metadata(panel.objmodel[uuid])
+ current = self._normalize_object_metadata(current)
+ saved = self._normalize_object_metadata(saved_metadata[uuid])
+ if saved and current != saved:
+ return False
+ return True
+
+ def restore(self, mainwindow: DLMainWindow) -> None:
+ """Restore the workspace state by selecting the recorded UUIDs.
+
+ Args:
+ mainwindow: DataLab's main window
+
+ Raises:
+ ValueError: If at least one of the saved UUIDs no longer exists in
+ its panel.
+ """
+ if not self.selection:
+ return
+ if not self.is_current_state_compatible(mainwindow, False):
+ raise ValueError(
+ "Current workspace state is not compatible with saved state"
+ )
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ uuids = self.selection.get(panel.PANEL_STR_ID, [])
+ if uuids:
+ panel.objview.select_objects(uuids)
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 4e867283..22358177 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -940,6 +940,15 @@ msgstr "Afficher le panneau de contraste"
msgid "Show or hide contrast adjustment panel"
msgstr "Afficher ou cacher le panneau de réglage du contraste"
+msgid "Command palette"
+msgstr "Palette de commandes"
+
+msgid "Type to search for a command…"
+msgstr "Tapez pour rechercher une commande…"
+
+msgid "Search a command…"
+msgstr "Rechercher une commande…"
+
msgid "Process signal"
msgstr "Traiter le signal"
@@ -1044,7 +1053,7 @@ msgstr "Ignorer ce message empêchera son affichage ultérieur."
msgid "Plugins"
msgstr "Plugins"
-msgid "Third-party plugins are disabled. Enable them in the Settings dialog to use this feature."
+msgid "Third-party plugins are disabled. Enable them again from the plugin configuration dialog to use this feature."
msgstr "Les plugins tiers sont désactivés. Activez-les dans la boîte de dialogue des préférences pour utiliser cette fonctionnalité."
#, python-format
@@ -1075,18 +1084,9 @@ msgstr "Préférences..."
msgid "Open settings dialog"
msgstr "Ouvrir la boîte de dialogue des préférences"
-msgid "Command palette"
-msgstr "Palette de commandes"
-
msgid "Command palette..."
msgstr "Palette de commandes..."
-msgid "Search a command…"
-msgstr "Rechercher une commande…"
-
-msgid "Type to search for a command…"
-msgstr "Tapez pour rechercher une commande…"
-
msgid "Search and run any command by its menu path"
msgstr "Rechercher et exécuter n'importe quelle commande par son chemin de menu"
@@ -1240,6 +1240,9 @@ msgstr "Console"
msgid "Macro Panel"
msgstr "Gestionnaire de Macros"
+msgid "History Panel"
+msgstr "Historique"
+
msgid "Disable auto-refresh?"
msgstr "Désactiver le rafraîchissement automatique ?"
@@ -1277,6 +1280,13 @@ msgstr "Ouvrir"
msgid "HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"
msgstr "Fichiers HDF5 (*.h5 *.hdf5 *.hdf *.he5);;Tous les fichiers (*)"
+#, python-format
+msgid "Open %d HDF5 files"
+msgstr "Ouvrir %d fichiers HDF5"
+
+msgid "Open HDF5 file"
+msgstr "Ouvrir un fichier HDF5"
+
msgid "not started"
msgstr "non démarré"
@@ -1349,9 +1359,8 @@ msgstr "Métadonnées de l'objet"
msgid "(click on Metadata button for more details)"
msgstr "(cliquer sur le bouton Métadonnées pour plus de détails)"
-#, fuzzy
msgid "group"
-msgstr "Groupe"
+msgstr "groupe"
msgid "Source objects"
msgstr "Objets source"
@@ -1424,6 +1433,12 @@ msgstr ""
msgid "Processing Parameters"
msgstr "Paramètres de traitement"
+msgid "Auto-recompute on edit"
+msgstr "Recalcul automatique lors de l'édition"
+
+msgid "Automatically re-run processing when parameters are modified"
+msgstr "Relancer automatiquement le traitement à chaque modification de paramètre"
+
msgid "No processing object available."
msgstr "Aucun objet de traitement disponible."
@@ -1468,6 +1483,9 @@ msgstr "Autres métadonnées"
msgid "Save to directory"
msgstr "Enregistrer dans un répertoire"
+msgid "Pattern help"
+msgstr "Aide sur le motif"
+
msgid "Directory"
msgstr "Répertoire"
@@ -1519,6 +1537,9 @@ msgstr "Modèle de valeur"
msgid "Conversion"
msgstr "Conversion"
+msgid "Duplicate object or group"
+msgstr "Dupliquer l'objet ou le groupe"
+
msgid "Select what to keep from the clipboard.
Result shapes and annotations, if kept, will be merged with existing ones. All other metadata will be replaced."
msgstr "Sélectionnez ce que vous souhaitez conserver dans le presse-papier.
Les formes graphiques et les annotations, si conservées, seront fusionnées avec celles existantes. Toutes les autres métadonnées seront remplacées."
@@ -1528,6 +1549,9 @@ msgstr "Supprimer le(s) groupe(s)"
msgid "Are you sure you want to delete the selected group(s)?"
msgstr "Êtes-vous sûr de vouloir supprimer le(s) groupe(s) sélectionné(s) ?"
+msgid "Remove selected objects"
+msgstr "Supprimer les objets sélectionnés"
+
#, python-format
msgid "Do you want to delete all objects (%s)?"
msgstr "Souhaitez-vous supprimer tous les objets (%s) ?"
@@ -1544,6 +1568,13 @@ msgstr "Nom du groupe :"
msgid "New group"
msgstr "Nouveau groupe"
+#, python-format
+msgid "New group \"%s\""
+msgstr "Nouveau groupe \"%s\""
+
+msgid "Rename selected object or group"
+msgstr "Renommer l'objet ou le groupe sélectionné"
+
msgid "Rename object"
msgstr "Renommer l'objet"
@@ -1553,6 +1584,10 @@ msgstr "Nom de l'objet :"
msgid "Rename group"
msgstr "Renommer le groupe"
+#, python-format
+msgid "Set current object title to \"%s\""
+msgstr "Définir le titre de l'objet courant à \"%s\""
+
msgid "Reading objects from file"
msgstr "Lecture des objets depuis le fichier"
@@ -1562,6 +1597,22 @@ msgstr "Ajout d'objets à l'espace de travail"
msgid "Scanning directory"
msgstr "Analyse du répertoire"
+#, python-format
+msgid "Load from %d files"
+msgstr "Charger depuis %d fichiers"
+
+#, python-format
+msgid "Load \"%s\""
+msgstr "Charger \"%s\""
+
+#, python-format
+msgid "Save to %d different files"
+msgstr "Enregistrer dans %d fichiers différents"
+
+#, python-format
+msgid "Save to \"%s\""
+msgstr "Enregistrer dans \"%s\""
+
msgid "Save as"
msgstr "Enregistrer sous"
@@ -1643,6 +1694,12 @@ msgstr "Tous les objets associés aux résultats doivent avoir les mêmes ROIs p
msgid "Are you sure you want to delete all results of the selected object(s)?"
msgstr "Êtes-vous sûr de vouloir supprimer tous les résultats des objets sélectionnés ?"
+msgid "Add object title to plot"
+msgstr "Ajouter le titre de l'objet au graphique"
+
+msgid "Add label with title"
+msgstr "Ajouter une étiquette avec le titre"
+
msgid "Annotation added"
msgstr "Annotation ajoutée"
@@ -1651,13 +1708,13 @@ msgstr "L'étiquette a été ajoutée comme annotation. Vous pouvez la modifier
#, python-format
msgid "“%s” has dependent operations but no valid source to reconnect to — downstream results are left unchanged."
-msgstr "“%s” a des opérations dépendantes mais aucune source valide à reconnecter — les résultats en aval restent inchangés."
+msgstr "« %s » possède des opérations dépendantes mais aucune source valide à laquelle se reconnecter — les résultats en aval restent inchangés."
msgid "Some operations could not be reconnected after deletion:"
msgstr "Certaines opérations n'ont pas pu être reconnectées après la suppression :"
msgid "The current workspace state is not compatible with the action."
-msgstr "Le statut actuel de l'espace de travail n'est pas compatible avec l'action."
+msgstr "L'état actuel de l'espace de travail n'est pas compatible avec l'action."
msgid "Parameters"
msgstr "Paramètres"
@@ -1669,10 +1726,10 @@ msgid "History files"
msgstr "Fichiers d'historique"
msgid "Edit mode"
-msgstr "Mode édition"
+msgstr "Mode d'édition"
msgid "Record mode"
-msgstr "Mode enregistrement"
+msgstr "Mode d'enregistrement"
msgid "New session"
msgstr "Nouvelle session"
@@ -1681,49 +1738,49 @@ msgid "Start a new history session"
msgstr "Démarrer une nouvelle session d'historique"
msgid "Open history file..."
-msgstr "Ouvrir des fichiers HDF5..."
+msgstr "Ouvrir un fichier d'historique..."
msgid "Open history from a standalone .dlhist file"
msgstr "Ouvrir l'historique depuis un fichier .dlhist autonome"
msgid "Save history file..."
-msgstr "Enregistrer l'historique dans un fichier HDF5..."
+msgstr "Enregistrer un fichier d'historique..."
msgid "Save history to a standalone .dlhist file"
msgstr "Enregistrer l'historique dans un fichier .dlhist autonome"
msgid "Duplicate selected history action/session"
-msgstr "Dupliquer l'objet %s sélectionné"
+msgstr "Dupliquer l'action ou la session d'historique sélectionnée"
msgid "Previous step"
msgstr "Étape précédente"
msgid "Select the previous action in the current session"
-msgstr "Sélectionner l'action précédente dans la session en cours"
+msgstr "Sélectionner l'action précédente dans la session courante"
msgid "Next step"
msgstr "Étape suivante"
msgid "Select the next action in the current session"
-msgstr "Sélectionner l'action suivante dans la session en cours"
+msgstr "Sélectionner l'action suivante dans la session courante"
msgid "Generate a Python macro script from history"
-msgstr "Générer un script Python macro à partir de l'historique"
+msgstr "Générer un script macro Python à partir de l'historique"
msgid "Remove actions incompatible with the current workspace"
-msgstr "Supprimer les actions incompatibles avec l'espace de travail actuel"
+msgstr "Supprimer les actions incompatibles avec l'espace de travail courant"
msgid "Restore parameters"
msgstr "Restaurer les paramètres"
msgid "Restore original parameters (discard edit-mode changes)"
-msgstr "Restaurer les paramètres d'origine (ignorer les modifications en mode édition)"
+msgstr "Restaurer les paramètres d'origine (annuler les modifications du mode d'édition)"
msgid "Replay"
msgstr "Rejouer"
msgid "Commit edit mode changes?"
-msgstr "Valider les modifications du mode édition ?"
+msgstr "Valider les modifications du mode d'édition ?"
msgid ""
"You are about to exit Edit mode.\n"
@@ -1733,24 +1790,24 @@ msgid ""
"\n"
"Do you want to continue?"
msgstr ""
-"Vous êtes sur le point de quitter le mode Édition.\n"
+"Vous êtes sur le point de quitter le mode d'édition.\n"
"\n"
-"Toutes les modifications de paramètres effectuées pendant cette session seront conservées de manière permanente.\n"
-"Cette action est irréversible — la restauration ne sera plus disponible.\n"
+"Toutes les modifications de paramètres effectuées lors de cette session seront conservées de façon permanente.\n"
+"Cette action est irréversible — la fonction Restaurer ne sera plus disponible.\n"
"\n"
-"Voulez-vous continuer ?"
+"Souhaitez-vous continuer ?"
#, python-format
msgid "Action %s has been edited but its target output object(s) no longer exist — skipping."
-msgstr "L'action %s a été modifiée mais son ou ses objets de sortie cibles n'existent plus — saut de l'action."
+msgstr "L'action %s a été modifiée mais son ou ses objets de sortie cibles n'existent plus — ignorée."
#, python-format
msgid "Action %s uses pattern %r which is not recomputable yet."
-msgstr "L'action %s utilise le modèle %r qui n'est pas encore retraitable."
+msgstr "L'action %s utilise le motif %r qui n'est pas encore recalculable."
#, python-format
msgid "Recompute failed for action %s: %s"
-msgstr "Le recalcul a échoué pour l'action %s : %s"
+msgstr "Échec du recalcul pour l'action %s : %s"
#, python-format
msgid ""
@@ -1764,29 +1821,32 @@ msgstr ""
#, python-format
msgid "Action %s: source object was deleted — skipping."
-msgstr "L'action %s : l'objet source a été supprimé — saut de l'action."
+msgstr "Action %s : l'objet source a été supprimé — ignorée."
#, python-format
msgid "Action %s: all source objects were deleted — skipping."
-msgstr "L'action %s : tous les objets source ont été supprimés — saut de l'action."
+msgstr "Action %s : tous les objets source ont été supprimés — ignorée."
#, python-format
msgid "Action %s: missing source(s) for output #%d — skipping."
-msgstr "L'action %s : source(s) manquante(s) pour la sortie n°%d — saut de l'action."
+msgstr "Action %s : source(s) manquante(s) pour la sortie n°%d — ignorée."
#, python-format
msgid "Action %s: source object(s) were deleted — skipping."
-msgstr "L'action %s : objet(s) source supprimé(s) — saut de l'action."
+msgstr "Action %s : le ou les objets source ont été supprimés — ignorée."
#, python-format
msgid "Action %s: %d analysed object(s) were deleted — skipping."
-msgstr "L'action %s : %d objet(s) analysé(s) ont été supprimé(s) — saut de l'action."
+msgstr "Action %s : %d objet(s) analysé(s) ont été supprimés — ignorée."
msgid "Cascade recompute"
-msgstr "Retraiter"
+msgstr "Recalcul en cascade"
msgid "Some downstream actions could not be recomputed:"
-msgstr "Certaines actions en aval n'ont pas pu être retraitées :"
+msgstr "Certaines actions en aval n'ont pas pu être recalculées :"
+
+msgid "New image"
+msgstr "Nouvelle image"
msgid "Recent macros"
msgstr "Macros récentes"
@@ -1795,7 +1855,7 @@ msgid "Clear all"
msgstr "Tout effacer"
msgid "Untitled"
-msgstr "(sans titre)"
+msgstr "Sans titre"
msgid "Clear all recent macros?"
msgstr "Effacer toutes les macros récentes ?"
@@ -1881,6 +1941,9 @@ msgstr "Lorsqu'elle est fermée, une macro est détruite de manière définit
msgid "Do you want to continue?"
msgstr "Souhaitez-vous vraiment continuer ?"
+msgid "New signal"
+msgstr "Nouveau signal"
+
msgid "Creating geometric shapes"
msgstr "Création des formes géométriques"
@@ -1921,18 +1984,24 @@ msgstr "Modifier le répertoire"
msgid "Remove directory"
msgstr "Supprimer le répertoire"
+msgid "from"
+msgstr "depuis"
+
msgid "Plugin Configuration"
msgstr "Configuration des plugins"
msgid "Enable/disable plugins"
msgstr "Activer/désactiver les plugins"
-msgid "Plugin search paths"
-msgstr "Chemins de recherche des plugins"
+msgid "Plugin settings"
+msgstr "Paramètres des plugins"
msgid "Apply and reload plugins"
msgstr "Appliquer et recharger les plugins"
+msgid "Third-party plugins are globally disabled."
+msgstr "Les plugins tiers sont désactivés globalement."
+
msgid "Changes will be applied after clicking OK and reloading plugins."
msgstr "Les modifications seront appliquées après avoir cliqué sur OK et rechargé les plugins."
@@ -1958,6 +2027,15 @@ msgstr "Aucun répertoire de plugins supplémentaire n'est configuré."
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 "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 "If enabled, DataLab will not warn you about v0.20 plugins that are no longer compatible with v1.0."
+msgstr "Si activé, DataLab ne vous avertira pas des plugins v0.20 qui ne sont plus compatibles avec v1.0."
+
msgid "Select plugin directory"
msgstr "Sélectionner un répertoire de plugins"
@@ -1979,32 +2057,17 @@ msgstr "Plugins désactivés"
msgid "Plugins with errors"
msgstr "Plugins en erreur"
-msgid "Reload Plugins"
-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 "Reload Plugins"
+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 "Failed to deserialize processing parameters from JSON."
msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON."
@@ -2050,9 +2113,6 @@ msgstr "En mode 'pairwise', vous devez sélectionner des objets dans au moins de
msgid "In pairwise mode, you need to select the same number of objects in each group."
msgstr "En mode 'pairwise', vous devez sélectionner le même nombre d'objets dans chaque groupe."
-msgid "Parameters"
-msgstr "Paramètres"
-
#, python-format
msgid "Calculating: %s"
msgstr "Calcul : %s"
@@ -2866,21 +2926,6 @@ msgstr "Mo"
msgid "Memory threshold below which a warning is displayed before loading any new data"
msgstr "Seuil de mémoire en dessous duquel un avertissement est affiché avant de charger de nouvelles données"
-msgid "Third-party plugins"
-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 "Ignore compatibility issues warning"
-msgstr "Ignorer l'avertissement de compatibilité"
-
-msgid "DataLab v0.20 plugins"
-msgstr "Plugins DataLab v0.20"
-
-msgid "If enabled, DataLab will not warn you about v0.20 plugins that are no longer compatible with v1.0."
-msgstr "Si activé, DataLab ne vous avertira pas des plugins v0.20 qui ne sont plus compatibles avec v1.0."
-
msgid "Settings for internal console, used for debugging or advanced users"
msgstr "Réglages de la console interne, utilisée pour le débogage ou les utilisateurs avancés"
@@ -3357,27 +3402,27 @@ msgid "You can show the tour again, or close this dialog box."
msgstr "Vous pouvez afficher la visite guidée à nouveau, ou fermer cette boîte de dialogue."
msgid "Save history file"
-msgstr "Enregistrer le fichier d'historique"
+msgstr "Enregistrer un fichier d'historique"
msgid "Open history file"
-msgstr "Ouvrir le fichier d'historique"
+msgstr "Ouvrir un fichier d'historique"
msgid "Imported"
-msgstr "Importer"
+msgstr "Importé"
msgid "Replaying compound 'multiple_1_to_1' actions is not supported yet."
msgstr "La relecture des actions composées 'multiple_1_to_1' n'est pas encore prise en charge."
msgid "Cannot replay 2-to-1 action: source object(s) missing."
-msgstr "Impossible de relire l'action 2-à-1 : objet(s) source manquant(s)."
+msgstr "Impossible de rejouer une action 2-à-1 : objet(s) source manquant(s)."
#, python-format
msgid "Failed to deserialize history DataSet kwarg %r."
-msgstr "Echec de la désérialisation de l'argument DataSet de l'historique %r."
+msgstr "Échec de la désérialisation de l'argument DataSet de l'historique %r."
#, python-format
msgid "Failed to deserialize history DataSet-list kwarg %r."
-msgstr "Echec de la désérialisation de l'argument DataSet-list de l'historique %r."
+msgstr "Échec de la désérialisation de l'argument DataSet-list de l'historique %r."
msgid "Session"
msgstr "Session"
@@ -3448,9 +3493,6 @@ msgstr "Créer une image avec un anneau"
msgid "Create image with a grid of gaussian spots"
msgstr "Créer une image avec une grille de spots gaussiens"
-msgid "New signal"
-msgstr "Nouveau signal"
-
msgid "Host application"
msgstr "Application hôte"
@@ -3802,12 +3844,12 @@ msgstr "Afficher les détails"
msgid "Hide details"
msgstr "Masquer les détails"
-msgid "Date and time"
-msgstr "Date et heure"
-
msgid "Title"
msgstr "Titre"
+msgid "Date and time"
+msgstr "Date et heure"
+
msgid "Action is compatible with the current workspace state."
msgstr "L'action est compatible avec l'état actuel de l'espace de travail."
@@ -3856,6 +3898,9 @@ msgstr "±{n} points"
msgid "±{n} rows × ±{n} columns"
msgstr "±{n} lignes × ±{n} colonnes"
+msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable."
+msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable."
+
msgid "Signal baseline selection"
msgstr "Sélection de la ligne de base du signal"
@@ -4095,9 +4140,6 @@ msgstr "Tout sélectionner"
msgid "Adding data to the plot"
msgstr "Ajout des données au graphique"
-msgid "Title"
-msgstr "Titre"
-
msgid "X label"
msgstr "Titre X"
@@ -4200,9 +4242,6 @@ msgstr "Image"
msgid "Dimensions"
msgstr "Dimensions"
-msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable."
-msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable."
-
msgid "Minimum value"
msgstr "Valeur minimum"
diff --git a/datalab/tests/features/common/auto_analysis_recompute_unit_test.py b/datalab/tests/features/common/auto_analysis_recompute_unit_test.py
index 326cf66a..7bf374ac 100644
--- a/datalab/tests/features/common/auto_analysis_recompute_unit_test.py
+++ b/datalab/tests/features/common/auto_analysis_recompute_unit_test.py
@@ -199,6 +199,10 @@ def test_analysis_recompute_after_recompute_1_to_1():
editor = panel.objprop.processing_param_editor
editor.dataset.angle = 90.0 # Change from 45° to 90°
+ # In-place recompute + automatic analysis recompute only happens when
+ # the History panel is in edit mode (otherwise a new object is created).
+ win.historypanel.toggle_edit_mode(True)
+
# Apply the modified parameters (this triggers recompute_1_to_1)
report = panel.objprop.apply_processing_parameters(interactive=False)
diff --git a/datalab/tests/features/common/history_app_test.py b/datalab/tests/features/common/history_app_test.py
new file mode 100644
index 00000000..211b1282
--- /dev/null
+++ b/datalab/tests/features/common/history_app_test.py
@@ -0,0 +1,196 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+History application test
+
+Exercises the History panel's full feature set:
+
+* Record-mode toggle gating
+* Entry recording for object creation, 1-to-1, 1-to-0, n-to-1 and 2-to-1
+ processing patterns (covering ``BaseProcessor.compute_*`` and
+ ``BaseDataPanel`` history-emitting methods)
+* Workspace state attached to processing entries
+* Session creation and session-aware indexing
+* Replay of a recorded action
+* Action deletion (cascade within a session)
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# guitest: show
+
+import sigima.objects
+import sigima.params
+import sigima.proc.signal as sips
+from qtpy import QtCore as QC
+from sigima.tests.data import create_paracetamol_signal
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.panel.history import HistoryAction, HistorySession, WorkspaceState
+from datalab.objectmodel import get_uuid
+from datalab.tests import datalab_test_app_context
+
+
+def _entry_titles(history) -> list[str]:
+ """Return the list of recorded entry titles, in chronological order."""
+ return [action.title for action in history]
+
+
+def _session_action_counts(history) -> list[int]:
+ """Return the number of recorded actions in each history session."""
+ return [len(session.actions) for session in history.history_sessions]
+
+
+def test_history_app():
+ """Run history application test scenario"""
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ dock = win.docks[history]
+ win.addDockWidget(QC.Qt.LeftDockWidgetArea, dock)
+ win.resize(int(win.width() * 1.7), win.height())
+ win.move(50, 50)
+ execenv.print("History application test:")
+
+ panel = win.signalpanel
+
+ # --- Record mode is OFF by default: nothing should be recorded ---------
+ assert len(history) == 0
+ panel.add_object(create_paracetamol_signal())
+ panel.processor.run_feature(sips.derivative)
+ assert len(history) == 0, (
+ "Record mode is disabled: no entry should have been recorded"
+ )
+
+ # Reset workspace before starting the recorded scenario.
+ # No history exists yet, so this must not create an empty session.
+ win.reset_all()
+ assert len(history) == 0
+ assert _session_action_counts(history) == []
+
+ # --- Enable record mode and start recording ----------------------------
+ history.toggle_record_mode(True)
+
+ # Pre-populate two real signals (``add_object`` does not record entries).
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ assert len(history) == 0
+
+ # 1) Object creation through the GUI path (BaseDataPanel.new_object):
+ # save_state=False, no workspace state is captured.
+ panel.new_object()
+ assert len(history) == 1
+ creation_entry = history[1]
+ assert isinstance(creation_entry, HistoryAction)
+ assert creation_entry.title == _("New signal")
+ assert creation_entry.state.selection == {}
+ assert creation_entry.state.states == {}
+
+ # 2) 1-to-1 processing (compute_1_to_1) on signal #1: save_state=True
+ panel.objview.select_objects([1])
+ obj1_uuid = get_uuid(panel.objmodel.get_object_from_number(1))
+ panel.processor.run_feature(sips.derivative)
+ assert len(history) == 2
+ deriv_entry = history[2]
+ assert deriv_entry.title # title must be non-empty
+ # Workspace state must remember the single-object selection (by UUID)
+ assert deriv_entry.state.selection.get(panel.PANEL_STR_ID) == [obj1_uuid]
+ assert len(deriv_entry.state.states.get(panel.PANEL_STR_ID, [])) == 1
+
+ # 3) 1-to-1 with parameters (compute_1_to_1 + DataSet param)
+ norm_param = sigima.params.NormalizeParam.create(method="maximum")
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.normalize, norm_param)
+ assert len(history) == 3
+ norm_entry = history[3]
+ # The recorded kwargs must include the parameter (used in description)
+ assert any(k.endswith("param") for k in norm_entry.kwargs)
+ assert norm_entry.description # description is built from the param
+
+ # 4) 1-to-0 analysis (compute_1_to_0)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.fwhm, sigima.params.FWHMParam())
+ assert len(history) == 4
+ fwhm_entry = history[4]
+ assert fwhm_entry.state.selection.get(panel.PANEL_STR_ID) == [obj1_uuid]
+
+ # 5) n-to-1 aggregation (compute_n_to_1) on signals #1 and #2
+ obj2 = panel.objmodel.get_object_from_number(2)
+ obj2_uuid = get_uuid(obj2)
+ panel.objview.select_objects([1, 2])
+ panel.processor.run_feature(sips.average)
+ assert len(history) == 5
+ avg_entry = history[5]
+ assert sorted(avg_entry.state.selection.get(panel.PANEL_STR_ID, [])) == sorted(
+ [obj1_uuid, obj2_uuid]
+ )
+
+ # 6) 2-to-1 binary operation (compute_2_to_1)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.difference, obj2)
+ assert len(history) == 6
+ diff_entry = history[6]
+ assert diff_entry.state.selection.get(panel.PANEL_STR_ID) == [obj1_uuid]
+
+ # --- Iteration / indexing API ------------------------------------------
+ all_titles = _entry_titles(history)
+ assert len(all_titles) == 6
+ assert all_titles[0] == _("New signal")
+ # Indexing is 1-based; iteration order matches index order
+ assert history[1] is creation_entry
+ assert history[6] is diff_entry
+
+ # --- Sessions ----------------------------------------------------------
+ history.create_new_session()
+ # New session does not change the action count
+ assert len(history) == 6
+ before = len(history)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.absolute)
+ after = len(history)
+ assert after == before + 1
+ new_session_entry = history[after]
+ assert new_session_entry.title
+
+ # --- Replay ------------------------------------------------------------
+ # Replaying the derivative entry must produce a new object without raising.
+ n_objects_before_replay = len(panel.objmodel)
+ deriv_entry.replay(win, restore_selection=True, edit=False)
+ assert len(panel.objmodel) > n_objects_before_replay
+
+ # --- Record mode OFF stops further recording ---------------------------
+ count_before_off = len(history)
+ history.toggle_record_mode(False)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.absolute)
+ assert len(history) == count_before_off, (
+ "Record mode disabled: subsequent operations must not be recorded"
+ )
+
+ # --- Workspace state types are well-formed -----------------------------
+ for action in history:
+ assert isinstance(action, HistoryAction)
+ assert isinstance(action.state, WorkspaceState)
+ assert isinstance(action.title, str)
+ assert isinstance(action.dtstr, str) and action.dtstr
+
+ # --- Delete cascade within a session -----------------------------------
+ # ``delete_selected`` itself opens a confirmation dialog; we exercise the
+ # underlying ``HistorySession.remove_action`` path used by it.
+ target = new_session_entry
+ target_session: HistorySession | None = None
+
+ for session in history.history_sessions:
+ if target in session.actions:
+ target_session = session
+ break
+ assert target_session is not None
+
+ n_before = len(history)
+ target_session.remove_action(target)
+ assert len(history) < n_before
+
+ execenv.print("==> OK")
+
+
+if __name__ == "__main__":
+ test_history_app()
diff --git a/datalab/tests/features/common/history_panel_app_test.py b/datalab/tests/features/common/history_panel_app_test.py
new file mode 100644
index 00000000..49786d51
--- /dev/null
+++ b/datalab/tests/features/common/history_panel_app_test.py
@@ -0,0 +1,64 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+History Panel application test
+(essentially for the screenshot...)
+
+Records a representative sequence of UI and computation actions and grabs
+a screenshot of the history panel, used in the documentation
+(:ref:`historypanel`).
+"""
+
+# guitest: show
+
+import sigima.objects
+import sigima.proc.signal as sips
+
+from datalab import config
+from datalab.tests import datalab_test_app_context
+from datalab.utils import qthelpers as qth
+
+
+def test_history_panel(screenshots: bool = False) -> None:
+ """Record a representative session and grab the History Panel screenshot."""
+ config.reset() # Reset configuration (remove configuration file and initialize it)
+ with datalab_test_app_context(console=False, exec_loop=not screenshots) as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+
+ panel = win.signalpanel
+
+ # [New Voigt, New Lorentzian, New Lorentzian]
+ panel.new_object(param=sigima.objects.VoigtParam(), edit=False)
+ panel.new_object(param=sigima.objects.LorentzParam(), edit=False)
+ panel.new_object(param=sigima.objects.LorentzParam(), edit=False)
+
+ # Remove the third signal
+ panel.objview.select_objects([3])
+ panel.remove_object(force=True)
+
+ # New Gaussian
+ panel.new_object(param=sigima.objects.GaussParam(), edit=False)
+
+ # Average of the 3 remaining signals
+ panel.objview.select_objects([1, 2, 3])
+ panel.processor.run_feature(sips.average)
+
+ # Add Gaussian noise to the average
+ noise_param = sigima.objects.NormalDistributionParam()
+ noise_param.sigma = 0.05
+ panel.objview.select_objects([4])
+ panel.processor.run_feature(sips.add_gaussian_noise, noise_param)
+
+ # Gaussian fit
+ panel.processor.run_feature(sips.gaussian_fit)
+
+ # Make sure the History Panel dock is raised over the Macro Panel
+ win.docks[history].raise_()
+
+ if screenshots:
+ qth.grab_save_window(history, "history_panel", add_timestamp=False)
+
+
+if __name__ == "__main__":
+ test_history_panel()
diff --git a/datalab/tests/features/common/history_test.py b/datalab/tests/features/common/history_test.py
new file mode 100644
index 00000000..9e6f51af
--- /dev/null
+++ b/datalab/tests/features/common/history_test.py
@@ -0,0 +1,1506 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+History panel — grouped exhaustive tests.
+
+Each ``test_history_*`` function bundles several closely-related scenarios
+that previously lived in dedicated tests across:
+
+* ``history_contract_unit_test.py`` (schema, compatibility, capture/replay
+ of UI actions, ROI clipboard, HDF5 round-trips, etc.)
+* ``history_replay_app_test.py`` (replay patterns, session replay,
+ duplication, stepping, tree selection, cascade, dlhist persistence,
+ chain reconnection)
+* ``history_app_test.py::test_history_reset_starts_new_session``
+
+Each scenario is delimited by a ``# --- scenario: ---`` comment.
+Scenarios sharing GUI state run inside a single ``datalab_test_app_context``
+block; truly independent pure-Python scenarios (HDF5 round-trip,
+``NotImplementedError`` smoke) run outside or in a nested block.
+
+Two GUI smoke tests are intentionally kept in their own modules:
+``history_app_test.py::test_history_app`` and
+``history_panel_app_test.py::test_history_panel``.
+"""
+
+# guitest: skip
+
+import os
+import shutil
+import tempfile
+
+import numpy as np
+import pytest
+import sigima.objects
+import sigima.params
+import sigima.proc.signal as sips
+from qtpy import QtCore as QC
+from sigima.objects import create_signal_roi
+from sigima.objects.base import BaseROI
+from sigima.objects.signal.creation import NewSignalParam
+from sigima.tests import helpers
+from sigima.tests.data import create_paracetamol_signal
+
+from datalab.config import _
+from datalab.gui.panel.base import AddMetadataParam, BaseDataPanel
+from datalab.gui.panel.history import (
+ HISTORY_ACTION_SCHEMA_VERSION,
+ HISTORY_SCHEMA_VERSION,
+ HistoryAction,
+ HistorySession,
+ HistoryTree,
+ WorkspaceState,
+)
+from datalab.gui.processor.base import extract_processing_parameters
+from datalab.h5.native import NativeH5Reader, NativeH5Writer
+from datalab.objectmodel import get_uuid
+from datalab.tests import datalab_test_app_context
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _delete_hdf5_items_by_name(group, item_name: str) -> None:
+ """Delete HDF5 attributes/groups named ``item_name`` recursively."""
+ if item_name in group.attrs:
+ del group.attrs[item_name]
+ if not hasattr(group, "keys"):
+ return
+ for key in list(group.keys()):
+ if key == item_name:
+ del group[key]
+ else:
+ _delete_hdf5_items_by_name(group[key], item_name)
+
+
+def _create_serializable_history_session() -> HistorySession:
+ """Create a history session requiring no application startup."""
+ state = WorkspaceState()
+ state.selection = {"signal": ["source-uuid"]}
+ state.states = {"signal": ["(10,)"]}
+ state.titles = {"signal": ["source"]}
+ state.object_metadata = {"signal": {"source-uuid": {"shape": [10], "ndim": 1}}}
+ action = HistoryAction(
+ title="Rename",
+ kind=HistoryAction.KIND_UI,
+ target="signalpanel",
+ method_name="set_current_object_title",
+ kwargs={"title": "renamed"},
+ state=state,
+ )
+ session = HistorySession(number=1)
+ session.add_action(action)
+ return session
+
+
+def _session_action_counts(history) -> list[int]:
+ """Return the number of recorded actions in each history session."""
+ return [len(session.actions) for session in history.history_sessions]
+
+
+def _get_tree_item_for(history, entry: HistoryAction):
+ """Return the tree item matching ``entry`` in the history tree."""
+ tree = history.tree
+ for i in range(tree.topLevelItemCount()):
+ sess_item = tree.topLevelItem(i)
+ for j in range(sess_item.childCount()):
+ child = sess_item.child(j)
+ if child.data(0, QC.Qt.UserRole) == entry.uuid:
+ return child
+ raise AssertionError(f"No tree item found for entry {entry.uuid}")
+
+
+def _select_tree_item_for(history, entry: HistoryAction) -> None:
+ """Select the tree item matching ``entry`` in the history tree."""
+ child = _get_tree_item_for(history, entry)
+ history.tree.clearSelection()
+ history.tree.setCurrentItem(child)
+ child.setSelected(True)
+
+
+def _select_tree_session(history, session) -> None:
+ """Select the tree item matching ``session`` in the history tree."""
+ sessions = history.history_sessions
+ index = sessions.index(session)
+ item = history.tree.topLevelItem(index)
+ history.tree.clearSelection()
+ history.tree.setCurrentItem(item)
+ item.setSelected(True)
+
+
+def _record_three_action_session(win):
+ """Helper: record [add_signal + normalize + derivative] in one session."""
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.normalize, sigima.params.NormalizeParam.create(method="maximum")
+ )
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.derivative)
+ return panel, history
+
+
+def _build_cascade_chain(panel, history):
+ """Build chain s001 -> gaussian -> s002 -> derivative -> s003 -> mavg -> s004.
+
+ Returns ``(action_a, action_b, action_c, output_b, output_c)``.
+ """
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5)
+ )
+ action_a = history[len(history)]
+
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.derivative)
+ action_b = history[len(history)]
+ output_b = panel.objmodel.get_object_from_number(3)
+
+ panel.objview.select_objects([3])
+ mavg = sigima.params.MovingAverageParam()
+ mavg.n = 3
+ panel.processor.run_feature(sips.moving_average, mavg)
+ action_c = history[len(history)]
+ output_c = panel.objmodel.get_object_from_number(4)
+ return action_a, action_b, action_c, output_b, output_c
+
+
+# ---------------------------------------------------------------------------
+# 1) Schema + HDF5 round-trips
+# ---------------------------------------------------------------------------
+
+
+def test_history_schema_and_hdf5_roundtrip():
+ """Schema persistence + HDF5 round-trip variants (.dlhist and .h5)."""
+ # --- scenario: schema_version is persisted ---
+ session = _create_serializable_history_session()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "history_schema.dlhist")
+ with NativeH5Writer(path) as writer:
+ writer.write_object_list([session], "history_session")
+ with NativeH5Reader(path) as reader:
+ restored_sessions = reader.read_object_list(
+ "history_session", HistorySession
+ )
+ assert len(restored_sessions) == 1
+ restored = restored_sessions[0]
+ assert restored.schema_version == HISTORY_SCHEMA_VERSION
+ assert restored.actions[0].schema_version == HISTORY_ACTION_SCHEMA_VERSION
+ assert restored.actions[0].kwargs == {"title": "renamed"}
+ assert restored.actions[0].state.object_metadata == {
+ "signal": {"source-uuid": {"shape": [10], "ndim": 1}}
+ }
+
+ # --- scenario: missing schema_version defaults to current ---
+ session = _create_serializable_history_session()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "history_schema_missing.dlhist")
+ with NativeH5Writer(path) as writer:
+ writer.write_object_list([session], "history_session")
+ _delete_hdf5_items_by_name(writer.h5, "schema_version")
+ with NativeH5Reader(path) as reader:
+ restored_sessions = reader.read_object_list(
+ "history_session", HistorySession
+ )
+ restored_action = restored_sessions[0].actions[0]
+ assert restored_sessions[0].schema_version == HISTORY_SCHEMA_VERSION
+ assert restored_action.schema_version == HISTORY_SCHEMA_VERSION
+ assert restored_action.title == "Rename"
+
+ # --- scenario: ROI kwargs survive HDF5 round-trip ---
+ roi = create_signal_roi([[26, 41]], indices=True)
+ state = WorkspaceState()
+ state.selection = {"signal": ["dst-uuid"]}
+ state.states = {"signal": ["(100,)"]}
+ state.titles = {"signal": ["dst"]}
+ state.object_metadata = {"signal": {"dst-uuid": {"shape": [100], "ndim": 1}}}
+ action = HistoryAction(
+ title="Paste ROI",
+ kind=HistoryAction.KIND_UI,
+ target="signalpanel",
+ method_name="paste_roi",
+ kwargs={"roi_data": roi},
+ state=state,
+ )
+ session = HistorySession(number=1)
+ session.add_action(action)
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "roi_roundtrip.dlhist")
+ with NativeH5Writer(path) as writer:
+ writer.write_object_list([session], "history_session")
+ with NativeH5Reader(path) as reader:
+ restored_sessions = reader.read_object_list(
+ "history_session", HistorySession
+ )
+ restored_roi = restored_sessions[0].actions[0].kwargs.get("roi_data")
+ assert restored_roi is not None
+ assert isinstance(restored_roi, BaseROI)
+ assert restored_roi.get_single_roi(0).coords.tolist() == [26, 41]
+
+ # --- scenario: legacy translated panel keys are normalized ---
+ state = WorkspaceState()
+ state.selection = {"Signal Panel": ["uuid-1"]}
+ state.states = {"Signal Panel": ["(10,)"]}
+ state.titles = {"Signal Panel": ["obj1"]}
+ state.object_metadata = {"Signal Panel": {"uuid-1": {"shape": [10], "ndim": 1}}}
+ legacy_action = HistoryAction(
+ title="Legacy",
+ kind=HistoryAction.KIND_UI,
+ target="signalpanel",
+ method_name="set_current_object_title",
+ kwargs={"title": "renamed"},
+ state=state,
+ )
+ legacy_session = HistorySession(number=1)
+ legacy_session.add_action(legacy_action)
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "legacy_keys.dlhist")
+ with NativeH5Writer(path) as writer:
+ writer.write_object_list([legacy_session], "history_session")
+ with NativeH5Reader(path) as reader:
+ restored_sessions = reader.read_object_list(
+ "history_session", HistorySession
+ )
+ rstate = restored_sessions[0].actions[0].state
+ assert "signal" in rstate.selection and "Signal Panel" not in rstate.selection
+ assert rstate.selection["signal"] == ["uuid-1"]
+ assert rstate.states["signal"] == ["(10,)"]
+ assert rstate.titles["signal"] == ["obj1"]
+ assert rstate.object_metadata["signal"] == {"uuid-1": {"shape": [10], "ndim": 1}}
+
+ # GUI-bound HDF5 scenarios run inside one app context.
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ panel = win.signalpanel
+
+ # --- scenario: deserialize from .h5 without history group does not raise ---
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "no_history.h5")
+ with NativeH5Writer(path) as writer:
+ panel.serialize_to_hdf5(writer)
+ with NativeH5Reader(path) as reader:
+ history.deserialize_from_hdf5(reader)
+ assert len(history) == 0
+
+ # --- scenario: HistoryAction HDF5 round-trip without pickle, then replay ---
+ history.toggle_record_mode(True)
+ panel.add_object(create_paracetamol_signal())
+ src_uuid = get_uuid(panel.objmodel.get_object_from_number(1))
+ norm_param = sigima.params.NormalizeParam.create(method="maximum")
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.normalize, norm_param)
+ original = history[len(history)]
+ ser_session = HistorySession(number=1)
+ ser_session.actions.append(original)
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "history.dlhist")
+ with NativeH5Writer(path) as writer:
+ writer.write_object_list([ser_session], "history_session")
+ with NativeH5Reader(path) as reader:
+ restored_sessions = reader.read_object_list(
+ "history_session", HistorySession
+ )
+ restored = restored_sessions[0].actions[0]
+ assert not hasattr(restored, "func")
+ assert restored.kind == HistoryAction.KIND_COMPUTE
+ assert restored.func_name == "normalize"
+ assert restored.pattern == "1_to_1"
+ assert restored.panel_str == panel.PANEL_STR_ID
+ restored_param = restored.kwargs.get("param")
+ assert restored_param is not None
+ assert type(restored_param).__name__ == type(norm_param).__name__
+ n_before = len(panel.objmodel)
+ restored.replay(win, restore_selection=True, edit=False)
+ assert len(panel.objmodel) == n_before + 1
+ new_obj = panel.objmodel.get_object_from_number(len(panel.objmodel))
+ new_pp = extract_processing_parameters(new_obj)
+ assert new_pp is not None
+ assert new_pp.source_uuid == src_uuid
+ assert new_pp.func_name == "normalize"
+
+ # --- scenario: workspace .h5 round-trip embeds history ---
+ recorded_titles = [a.title for a in history]
+ recorded_func_names = [a.func_name for a in history]
+ recorded_kinds = [a.kind for a in history]
+ assert len(history) >= 1
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
+ path = os.path.join(tmpdir, "workspace.h5")
+ win.save_h5_workspace(path)
+ win.reset_all()
+ assert len(panel.objmodel) == 0
+ win.load_h5_workspace([path], reset_all=True)
+ reloaded = win.historypanel
+ reloaded_titles = [a.title for a in reloaded]
+ for title in recorded_titles:
+ assert title in reloaded_titles
+ for func_name in recorded_func_names:
+ if func_name is not None:
+ assert func_name in [a.func_name for a in reloaded]
+ for kind in recorded_kinds:
+ assert kind in [a.kind for a in reloaded]
+
+ # --- scenario: standalone .dlhist round-trip (import path on non-empty WS) ---
+ win.reset_all()
+ history = win.historypanel
+ panel = win.signalpanel
+ history.toggle_record_mode(True)
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.normalize, sigima.params.NormalizeParam.create(method="maximum")
+ )
+ original_titles = [a.title for a in history]
+ original_func_names = [a.func_name for a in history]
+ assert len(original_titles) >= 2
+ n_actions_before = len(history)
+ n_signals_before = len(panel.objmodel)
+ n_sessions_before = len(history.history_sessions)
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
+ path = os.path.join(tmpdir, "history_panel.dlhist")
+ assert history.save_to_dlhist_file(path)
+ assert history.open_dlhist_file(path)
+ reloaded_titles = [a.title for a in history]
+ reloaded_func_names = [a.func_name for a in history]
+ for title in original_titles:
+ assert title in reloaded_titles
+ for func_name in original_func_names:
+ if func_name is not None:
+ assert func_name in reloaded_func_names
+ assert len(history.history_sessions) > n_sessions_before
+ assert len(panel.objmodel) > n_signals_before
+ assert len(history) > n_actions_before
+
+ # --- scenario: .dlhist self-contained — direct-load into fresh empty workspace ---
+ tmpdir = tempfile.mkdtemp()
+ try:
+ path = os.path.join(tmpdir, "test.dlhist")
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ original_titles = [a.title for a in history]
+ original_func_names = [a.func_name for a in history]
+ assert len(original_titles) >= 1
+ assert history.save_to_dlhist_file(path)
+ with datalab_test_app_context() as win2:
+ history2 = win2.historypanel
+ panel2 = win2.signalpanel
+ assert len(panel2.objmodel) == 0
+ assert len(history2.history_sessions) == 0
+ assert history2.open_dlhist_file(path)
+ reloaded_titles = [a.title for a in history2]
+ reloaded_func_names = [a.func_name for a in history2]
+ assert reloaded_titles == original_titles
+ for func_name in original_func_names:
+ if func_name is not None:
+ assert func_name in reloaded_func_names
+ assert len(panel2.objmodel) >= 1
+ finally:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
+# ---------------------------------------------------------------------------
+# 2) HistoryAction / WorkspaceState compatibility
+# ---------------------------------------------------------------------------
+
+
+def test_history_action_compatibility():
+ """``HistoryAction`` compatibility (UUID/shape/legacy fallback + tree marker)."""
+ # --- scenario: incompatible when selected UUID disappears ---
+ with datalab_test_app_context() as win:
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ obj = panel.objmodel.get_object_from_number(1)
+ panel.objview.set_current_object(obj)
+ state = WorkspaceState()
+ state.save(win)
+ action = HistoryAction(title="Dummy", state=state)
+ assert action.is_current_state_compatible(win, restore_selection=False)
+ panel.remove_object(force=True)
+ assert not action.is_current_state_compatible(win, restore_selection=False)
+
+ # --- scenario: incompatible when selected object shape changes ---
+ win.reset_all()
+ panel.add_object(create_paracetamol_signal())
+ obj = panel.objmodel.get_object_from_number(1)
+ panel.objview.set_current_object(obj)
+ state = WorkspaceState()
+ state.save(win)
+ action = HistoryAction(title="Dummy", state=state)
+ assert action.is_current_state_compatible(win, restore_selection=False)
+ obj.set_xydata(obj.x[:-1], obj.y[:-1])
+ assert not action.is_current_state_compatible(win, restore_selection=False)
+
+ # --- scenario: histories without object_metadata fall back to UUID check ---
+ win.reset_all()
+ panel.add_object(create_paracetamol_signal())
+ obj = panel.objmodel.get_object_from_number(1)
+ panel.objview.set_current_object(obj)
+ state = WorkspaceState()
+ state.save(win)
+ action = HistoryAction(title="Dummy", state=state)
+ session = HistorySession(number=1)
+ session.add_action(action)
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "history_without_gate2_metadata.dlhist")
+ with NativeH5Writer(path) as writer:
+ writer.write_object_list([session], "history_session")
+ _delete_hdf5_items_by_name(writer.h5, "object_metadata")
+ with NativeH5Reader(path) as reader:
+ restored_sessions = reader.read_object_list(
+ "history_session", HistorySession
+ )
+ restored_action = restored_sessions[0].actions[0]
+ assert restored_action.state.object_metadata == {}
+ assert restored_action.is_current_state_compatible(win, restore_selection=False)
+
+ # --- scenario: tree marks incompatible action after source deletion ---
+ win.reset_all()
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ deriv_entry = history[len(history)]
+ item = _get_tree_item_for(history, deriv_entry)
+ assert item.data(0, HistoryTree.COMPATIBILITY_ROLE) is True
+ history.toggle_record_mode(False)
+ panel.objview.select_objects([1])
+ panel.remove_object(force=True)
+ history.refresh_compatibility_items()
+ item = _get_tree_item_for(history, deriv_entry)
+ assert item.data(0, HistoryTree.COMPATIBILITY_ROLE) is False
+ assert item.foreground(0).color().isValid()
+
+
+# ---------------------------------------------------------------------------
+# 3) Recording: compute + UI actions
+# ---------------------------------------------------------------------------
+
+
+def test_history_recording_compute_and_ui(monkeypatch):
+ """Recording and replay of compute + UI actions (capture fidelity)."""
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ src_uuid = get_uuid(panel.objmodel.get_object_from_number(1))
+
+ # --- scenario: compute_1_to_1 history matches ProcessingParameters ---
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ result_obj = panel.objmodel.get_object_from_number(2)
+ pp = extract_processing_parameters(result_obj)
+ assert pp is not None
+ assert pp.func_name == "derivative"
+ assert pp.source_uuid == src_uuid
+ entry = history[len(history)]
+ assert entry.kind == HistoryAction.KIND_COMPUTE
+ assert entry.func_name == "derivative"
+ assert entry.pattern == "1_to_1"
+ assert entry.state.selection.get(panel.PANEL_STR_ID) == [src_uuid]
+
+ # --- scenario: recompute_processing does not add a history entry ---
+ n_before = len(history)
+ derived = panel.objmodel.get_object_from_number(2)
+ panel.objview.set_current_object(derived)
+ panel.recompute_processing()
+ assert len(history) == n_before
+
+ # --- scenario: replay finds target by UUID after panel reorder ---
+ deriv_entry = history[len(history)]
+ panel.add_object(create_paracetamol_signal())
+ assert get_uuid(panel.objmodel[src_uuid]) == src_uuid
+ n_before_replay = len(panel.objmodel)
+ deriv_entry.replay(win, restore_selection=True, edit=False)
+ assert len(panel.objmodel) == n_before_replay + 1
+ new_obj = panel.objmodel.get_object_from_number(len(panel.objmodel))
+ new_pp = extract_processing_parameters(new_obj)
+ assert new_pp is not None
+ assert new_pp.source_uuid == src_uuid
+
+ # --- scenario: UI rename capture + replay ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ obj = panel.objmodel.get_object_from_number(1)
+ original_title = obj.title
+ new_title = "renamed-by-test"
+ panel.objview.set_current_object(obj)
+ panel.set_current_object_title(new_title)
+ assert obj.title == new_title
+ rename_entry = history[len(history)]
+ assert rename_entry.kind == HistoryAction.KIND_UI
+ assert rename_entry.target == "signalpanel"
+ assert rename_entry.method_name == "set_current_object_title"
+ assert rename_entry.kwargs.get("title") == new_title
+ panel.set_current_object_title("transient-title")
+ assert obj.title == "transient-title"
+ rename_entry.replay(win, restore_selection=False, edit=False)
+ assert obj.title == new_title and obj.title != original_title
+ assert isinstance(rename_entry.state, WorkspaceState)
+
+ # --- scenario: add_metadata capture ---
+ obj_uuid = get_uuid(obj)
+ panel.objview.select_objects([1])
+ param = AddMetadataParam([obj])
+ param.metadata_key = "history_gate6"
+ param.value_pattern = "value_{index}"
+ param.conversion = "string"
+ panel.add_metadata(param)
+ entry = history[len(history)]
+ assert entry.kind == HistoryAction.KIND_UI
+ assert entry.target == "signalpanel"
+ assert entry.method_name == "add_metadata"
+ captured = entry.kwargs.get("param")
+ assert captured is not None and captured is not param
+ assert captured.metadata_key == param.metadata_key
+ assert captured.value_pattern == param.value_pattern
+ assert captured.conversion == param.conversion
+ assert entry.state.selection.get(panel.PANEL_STR_ID) == [obj_uuid]
+
+ # --- scenario: add_metadata replay ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ obj = panel.objmodel.get_object_from_number(1)
+ panel.objview.select_objects([1])
+ param = AddMetadataParam([obj])
+ param.metadata_key = "replay_test_key"
+ param.value_pattern = "replay_value_{index}"
+ param.conversion = "string"
+ panel.add_metadata(param)
+ assert obj.metadata.get("replay_test_key") == "replay_value_1"
+ entry = history[len(history)]
+ del obj.metadata["replay_test_key"]
+ assert "replay_test_key" not in obj.metadata
+ entry.replay(win, restore_selection=False, edit=False)
+ assert obj.metadata.get("replay_test_key") == "replay_value_1"
+
+ # --- scenario: ROI copy/paste capture + deterministic replay ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ src = create_paracetamol_signal()
+ src.roi = create_signal_roi([[26, 41]], indices=True)
+ panel.add_object(src)
+ dst = create_paracetamol_signal()
+ panel.add_object(dst)
+ src_uuid = get_uuid(src)
+ dst_uuid = get_uuid(dst)
+ panel.objview.set_current_item_id(src_uuid)
+ panel.copy_roi()
+ copy_entry = history[len(history)]
+ panel.objview.set_current_item_id(dst_uuid)
+ panel.paste_roi()
+ paste_entry = history[len(history)]
+ assert copy_entry.kind == HistoryAction.KIND_UI
+ assert copy_entry.target == "signalpanel"
+ assert copy_entry.method_name == "copy_roi"
+ assert "roi_data" in copy_entry.kwargs
+ assert copy_entry.state.selection.get(panel.PANEL_STR_ID) == [src_uuid]
+ assert paste_entry.kind == HistoryAction.KIND_UI
+ assert paste_entry.target == "signalpanel"
+ assert paste_entry.method_name == "paste_roi"
+ assert "roi_data" in paste_entry.kwargs
+ assert paste_entry.state.selection.get(panel.PANEL_STR_ID) == [dst_uuid]
+ # Deterministic replay: change source ROI then replay paste.
+ dst_obj = panel.objmodel.get_object_from_number(2)
+ assert dst_obj.roi is not None
+ src.roi = create_signal_roi([[100, 200]], indices=True)
+ dst_obj.roi = None
+ paste_entry.replay(win, restore_selection=False, edit=False)
+ assert dst_obj.roi is not None
+ assert dst_obj.roi.get_single_roi(0).coords.tolist() == [26, 41]
+
+ # --- scenario: save_to_directory capture + replay ---
+ saved_paths: list[str] = []
+
+ def fake_save_to_file(_self, _obj, filename):
+ saved_paths.append(filename)
+
+ monkeypatch.setattr(
+ BaseDataPanel, "_BaseDataPanel__save_to_file", fake_save_to_file
+ )
+ with datalab_test_app_context() as win:
+ with helpers.WorkdirRestoringTempDir() as tmpdir:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ obj = panel.objmodel.get_object_from_number(1)
+ obj_uuid = get_uuid(obj)
+ panel.objview.select_objects([1])
+ param = sigima.params.SaveToDirectoryParam.create(
+ directory=tmpdir,
+ basename="history_gate6_{index}",
+ extension=".csv",
+ overwrite=True,
+ )
+ panel.save_to_directory(param)
+ entry = history[len(history)]
+ assert entry.kind == HistoryAction.KIND_UI
+ assert entry.target == "signalpanel"
+ assert entry.method_name == "save_to_directory"
+ captured = entry.kwargs.get("param")
+ assert captured is not None and captured is not param
+ assert captured.directory == param.directory
+ assert captured.basename == param.basename
+ assert captured.extension == param.extension
+ assert captured.overwrite == param.overwrite
+ assert entry.state.selection.get(panel.PANEL_STR_ID) == [obj_uuid]
+ assert saved_paths == [os.path.join(tmpdir, "history_gate6_1.csv")]
+ # Replay: must call save again with same parameters.
+ n_before = len(saved_paths)
+ entry.replay(win, restore_selection=False, edit=False)
+ assert len(saved_paths) == n_before + 1
+ assert saved_paths[-1] == saved_paths[-2]
+
+
+# ---------------------------------------------------------------------------
+# 4) Replay patterns (1_to_n, n_to_1, 2_to_1, multiple_1_to_1, normal)
+# ---------------------------------------------------------------------------
+
+
+def test_history_replay_patterns(monkeypatch):
+ """Replay behaviour for each compute pattern (persistent + non-persistent)."""
+ # --- scenario: multiple_1_to_1 replay raises NotImplementedError ---
+ with datalab_test_app_context() as win:
+ action = HistoryAction(
+ title="dummy multiple_1_to_1",
+ kind=HistoryAction.KIND_COMPUTE,
+ panel_str="signal",
+ func_name="some_compound_op",
+ pattern="multiple_1_to_1",
+ state=WorkspaceState(),
+ )
+ with pytest.raises(NotImplementedError):
+ action.replay(win, restore_selection=False, edit=False)
+
+ # --- scenario: normal processing outside replay still adds objects ---
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ n_before = len(panel.objmodel)
+ panel.processor.run_feature(sips.derivative)
+ assert len(panel.objmodel) == n_before + 1
+
+ # --- scenario: 1_to_n extract_roi replay (persistent direct replay) ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ sig = create_paracetamol_signal()
+ sig.roi = sigima.objects.create_signal_roi([[26, 41], [125, 146]], indices=True)
+ panel.add_object(sig)
+ src_uuid = get_uuid(panel.objmodel.get_object_from_number(1))
+ n_objects_before = len(panel.objmodel)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature("extract_roi", params=sig.roi.to_params(sig))
+ n_added_first = len(panel.objmodel) - n_objects_before
+ assert n_added_first >= 1
+ entry = history[len(history)]
+ assert entry.kind == HistoryAction.KIND_COMPUTE
+ assert entry.pattern == "1_to_n"
+ assert entry.func_name == "extract_roi"
+ assert entry.state.selection.get(panel.PANEL_STR_ID) == [src_uuid]
+ n_before_replay = len(panel.objmodel)
+ entry.replay(win, restore_selection=True, edit=False)
+ assert len(panel.objmodel) - n_before_replay == n_added_first
+
+ # --- scenario: 1_to_n via panel API does NOT add output (non-persistent) ---
+ _select_tree_item_for(history, entry)
+ n_before = len(panel.objmodel)
+ n_hist_before = len(history)
+ history.replay_restore_actions(replay=True, restore_selection=True)
+ assert len(panel.objmodel) == n_before
+ assert len(history) == n_hist_before
+
+ # --- scenario: n_to_1 forces captured selection ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1, 2, 3])
+ panel.processor.run_feature(sips.average)
+ avg_entry = history[len(history)]
+ assert avg_entry.pattern == "n_to_1"
+ panel.objview.select_objects([1])
+ assert len(panel.objview.get_sel_object_uuids()) == 1
+ n_before = len(panel.objmodel)
+ avg_entry.replay(win, restore_selection=False, edit=False)
+ assert len(panel.objmodel) == n_before + 1
+
+ # --- scenario: n_to_1 via panel API does NOT add output ---
+ _select_tree_item_for(history, avg_entry)
+ n_before = len(panel.objmodel)
+ n_hist_before = len(history)
+ history.replay_restore_actions(replay=True, restore_selection=True)
+ assert len(panel.objmodel) == n_before
+ assert len(history) == n_hist_before
+
+ # --- scenario: n_to_1 falls back when captured UUIDs are gone ---
+ win.reset_all()
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1, 2, 3])
+ assert not avg_entry.state.is_current_state_compatible(win, False)
+ n_before = len(panel.objmodel)
+ avg_entry.replay(win, restore_selection=False, edit=False)
+ assert len(panel.objmodel) == n_before + 1
+
+ # --- scenario: n_to_1 passes recorded pairwise flag ---
+ with datalab_test_app_context() as win:
+ panel = win.signalpanel
+ captured: dict = {}
+
+ def capture_compute_n_to_1(*_args, **kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr(panel.processor, "compute_n_to_1", capture_compute_n_to_1)
+ action = HistoryAction(
+ title="average pairwise",
+ kind=HistoryAction.KIND_COMPUTE,
+ panel_str=panel.PANEL_STR_ID,
+ func_name="average",
+ pattern="n_to_1",
+ kwargs={"pairwise": True},
+ state=WorkspaceState(),
+ )
+ action.replay_compute(win, edit=False)
+ assert captured["pairwise"] is True
+
+ # --- scenario: 2_to_1 with vanished obj2 raises ValueError ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ obj2 = panel.objmodel.get_object_from_number(2)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.difference, obj2)
+ diff_entry = history[len(history)]
+ assert diff_entry.pattern == "2_to_1"
+ panel.objview.set_current_object(obj2)
+ panel.remove_object(force=True)
+ with pytest.raises(ValueError):
+ diff_entry.replay(win, restore_selection=False, edit=False)
+
+ # --- scenario: 2_to_1 replay translates obj2 UUIDs and passes pairwise ---
+ with datalab_test_app_context() as win:
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ obj2 = panel.objmodel.get_object_from_number(1)
+ obj2_uuid = get_uuid(obj2)
+ captured = {}
+
+ def capture_compute_2_to_1(obj2_arg, *_args, **kwargs):
+ captured["obj2"] = obj2_arg
+ captured.update(kwargs)
+
+ monkeypatch.setattr(panel.processor, "compute_2_to_1", capture_compute_2_to_1)
+ action = HistoryAction(
+ title="difference pairwise",
+ kind=HistoryAction.KIND_COMPUTE,
+ panel_str=panel.PANEL_STR_ID,
+ func_name="difference",
+ pattern="2_to_1",
+ kwargs={"obj2_uuids": ["recorded-obj2"], "pairwise": True},
+ state=WorkspaceState(),
+ )
+ action.replay_compute(
+ win,
+ edit=False,
+ uuid_remap={panel.PANEL_STR_ID: {"recorded-obj2": obj2_uuid}},
+ )
+ assert captured["obj2"] is obj2
+ assert captured["pairwise"] is True
+
+ # scenario: replay_restore_actions(replay=True) on 1_to_1 does NOT add output
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ deriv_entry = history[len(history)]
+ _select_tree_item_for(history, deriv_entry)
+ n_signal_before = len(panel.objmodel)
+ n_history_before = len(history)
+ history.replay_restore_actions(replay=True, restore_selection=True)
+ assert len(panel.objmodel) == n_signal_before
+ assert len(history) == n_history_before
+
+
+# ---------------------------------------------------------------------------
+# 5) Session-level replay (+ reset-starts-new-session)
+# ---------------------------------------------------------------------------
+
+
+def test_history_session_replay():
+ """Full ``HistorySession.replay`` behaviour + reset/session boundaries."""
+ # --- scenario: reset_all starts a new session and preserves history ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ panel = win.signalpanel
+ win.reset_all()
+ assert len(history) == 0
+ assert _session_action_counts(history) == []
+ history.toggle_record_mode(True)
+ panel.new_object(param=sigima.objects.GaussParam(), edit=False)
+ assert len(history) == 1
+ assert _session_action_counts(history) == [1]
+ first_title = history[1].title
+ history.toggle_record_mode(False)
+ win.reset_all()
+ assert len(history) == 1
+ assert _session_action_counts(history) == [1, 0]
+ assert history[1].title == first_title
+ history.toggle_record_mode(True)
+ panel.new_object(param=sigima.objects.LorentzParam(), edit=False)
+ assert len(history) == 2
+ assert _session_action_counts(history) == [1, 1]
+ assert history[1].title == first_title
+ assert history[2].title == _("New signal")
+
+ # --- scenario: full session replay on existing data ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ assert len(panel.objmodel) == 3
+ panel.objview.select_objects([1, 2, 3])
+ panel.processor.run_feature(sips.average)
+ assert len(panel.objmodel) == 4
+ session = history.history_sessions[-1]
+ n_before = len(panel.objmodel)
+ session.replay(win, restore_selection=False, edit=False)
+ assert len(panel.objmodel) == n_before + 1
+
+ # --- scenario: chained compute session replay (output queue remap) ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.derivative)
+ panel.objview.select_objects([3, 4])
+ panel.processor.run_feature(sips.average)
+ assert len(panel.objmodel) == 5
+ session = history.history_sessions[-1]
+ n_before = len(panel.objmodel)
+ session.replay(win, restore_selection=False, edit=False)
+ assert len(panel.objmodel) == n_before + 3
+
+ # --- scenario: direct HistoryAction.replay() does NOT record new entries ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ panel = win.signalpanel
+ history.toggle_record_mode(True)
+ panel.new_object(param=NewSignalParam(), edit=False)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ assert len(panel.objmodel) == 2
+ session = history.history_sessions[-1]
+ compute_action = [
+ a for a in session.actions if a.kind == HistoryAction.KIND_COMPUTE
+ ][0]
+ n_before = sum(len(s.actions) for s in history.history_sessions)
+ panel.objview.select_objects([1])
+ compute_action.replay(win, restore_selection=True, edit=False)
+ assert sum(len(s.actions) for s in history.history_sessions) == n_before
+
+ # --- scenario: direct HistorySession.replay() does NOT record entries ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ panel = win.signalpanel
+ history.toggle_record_mode(True)
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1, 2])
+ panel.processor.run_feature(sips.average)
+ assert len(panel.objmodel) == 3
+ session = history.history_sessions[-1]
+ n_before = sum(len(s.actions) for s in history.history_sessions)
+ panel.objview.select_objects([1, 2])
+ session.replay(win, restore_selection=False, edit=False)
+ assert sum(len(s.actions) for s in history.history_sessions) == n_before
+
+ # --- scenario: panel API session replay skips UI-creation, no output ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.new_object(param=NewSignalParam(), edit=False)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ session = history.history_sessions[-1]
+ _select_tree_session(history, session)
+ n_signal_before = len(panel.objmodel)
+ n_history_before = len(history)
+ history.replay_restore_actions(replay=True, restore_selection=True)
+ assert len(panel.objmodel) == n_signal_before
+ assert len(history) == n_history_before
+
+ # --- scenario: replay whole session when no tree selection ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ history.tree.clearSelection()
+ n_before = len(panel.objmodel)
+ n_history_before = len(history)
+ history.replay_restore_actions(replay=True, restore_selection=False)
+ assert len(panel.objmodel) == n_before
+ assert len(history) == n_history_before
+
+ # --- scenario: 2_to_1 preserves operand order (primary = #2) ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ obj2_for_diff = panel.objmodel.get_object_from_number(1)
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.difference, obj2_for_diff)
+ assert len(panel.objmodel) == 3
+ original_title = panel.objmodel.get_object_from_number(3).title
+ assert "s002" in original_title and "s001" in original_title
+ assert original_title.index("s002") < original_title.index("s001")
+ diff_entry = history[len(history)]
+ _select_tree_item_for(history, diff_entry)
+ n_before = len(panel.objmodel)
+ history.replay_restore_actions(replay=True, restore_selection=True)
+ assert len(panel.objmodel) == n_before
+
+ # --- scenario: 2_to_1 with primary = #1, obj2 = #2 ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ obj2_for_diff = panel.objmodel.get_object_from_number(2)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.difference, obj2_for_diff)
+ assert len(panel.objmodel) == 3
+ original_title = panel.objmodel.get_object_from_number(3).title
+ assert "s001" in original_title and "s002" in original_title
+ assert original_title.index("s001") < original_title.index("s002")
+ diff_entry = history[len(history)]
+ _select_tree_item_for(history, diff_entry)
+ n_before = len(panel.objmodel)
+ history.replay_restore_actions(replay=True, restore_selection=True)
+ assert len(panel.objmodel) == n_before
+
+ # --- scenario: 1_to_1 on second signal (derivative on #2) ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.derivative)
+ assert len(panel.objmodel) == 3
+ original_title = panel.objmodel.get_object_from_number(3).title
+ assert "s002" in original_title
+ deriv_entry = history[len(history)]
+ _select_tree_item_for(history, deriv_entry)
+ n_before = len(panel.objmodel)
+ history.replay_restore_actions(replay=True, restore_selection=True)
+ assert len(panel.objmodel) == n_before
+
+
+# ---------------------------------------------------------------------------
+# 6) Duplication
+# ---------------------------------------------------------------------------
+
+
+def test_history_duplication():
+ """Duplication of actions/sessions + replay of duplicates + ordering."""
+ # --- scenario: duplicating an action creates an independent copy ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ param = sigima.params.MovingAverageParam()
+ param.n = 3
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.moving_average, param)
+ original = history[len(history)]
+ _select_tree_item_for(history, original)
+ n_before = len(panel.objmodel)
+ history.duplicate_selected_entries()
+ sessions = history.history_sessions
+ duplicate_session = sessions[-1]
+ duplicate = duplicate_session.actions[0]
+ assert duplicate_session.title.endswith(_("Copy"))
+ assert duplicate is not original
+ assert duplicate.uuid != original.uuid
+ assert duplicate.kwargs["param"] is not original.kwargs["param"]
+ duplicate.kwargs["param"].n = 7
+ assert original.kwargs["param"].n == 3
+ assert duplicate.kwargs["param"].n == 7
+ assert len(panel.objmodel) > n_before
+
+ # --- scenario: duplicating a session copies all actions independently ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ param = sigima.params.MovingAverageParam()
+ param.n = 3
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.moving_average, param)
+ original_session = history.history_sessions[-1]
+ _select_tree_session(history, original_session)
+ history.duplicate_selected_entries()
+ sessions = history.history_sessions
+ duplicate_session = sessions[-1]
+ assert duplicate_session is not original_session
+ assert len(duplicate_session.actions) == len(original_session.actions)
+ assert duplicate_session.title.endswith(_("Copy"))
+ orig_a = original_session.actions[-1]
+ dup_a = duplicate_session.actions[-1]
+ assert dup_a is not orig_a
+ assert dup_a.kwargs["param"] is not orig_a.kwargs["param"]
+
+ # --- scenario: duplicate clones data AND remaps UUIDs ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ original_session = history.history_sessions[-1]
+ _select_tree_session(history, original_session)
+ n_obj_before = len(panel.objmodel)
+ n_sessions_before = len(history.history_sessions)
+ history.duplicate_selected_entries()
+ sessions = history.history_sessions
+ assert len(sessions) == n_sessions_before + 1
+ dup_session = sessions[-1]
+ assert dup_session is not original_session
+ assert dup_session.title.endswith(_("Copy"))
+ assert len(panel.objmodel) > n_obj_before
+ for orig_action, dup_action in zip(
+ original_session.actions, dup_session.actions
+ ):
+ for pstr in orig_action.state.selection:
+ orig_uuids = set(orig_action.state.selection.get(pstr, []))
+ dup_uuids = set(dup_action.state.selection.get(pstr, []))
+ if orig_uuids and dup_uuids:
+ assert orig_uuids.isdisjoint(dup_uuids)
+
+ # --- scenario: replay of duplicated session does NOT add output ---
+ _select_tree_session(history, dup_session)
+ n_before = len(panel.objmodel)
+ n_history_before = len(history)
+ history.replay_restore_actions(replay=True, restore_selection=False)
+ assert len(panel.objmodel) == n_before
+ assert len(history) == n_history_before
+
+ # --- scenario: duplicated session preserves topological object order ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ _build_cascade_chain(panel, history)
+ # Extra step so the moving_average output is captured in metadata.
+ panel.objview.select_objects([4])
+ panel.processor.run_feature(sips.derivative)
+ original_ids = panel.objmodel.get_object_ids()
+ original_titles = [panel.objmodel[uid].title for uid in original_ids]
+ assert len(original_titles) >= 3
+ sessions = history.history_sessions
+ _select_tree_session(history, sessions[0])
+ history.duplicate_selected_entries()
+ groups = panel.objmodel.get_groups()
+ assert len(groups) >= 2
+ dup_group_id = get_uuid(groups[-1])
+ dup_ids = [
+ uid
+ for uid in panel.objmodel.get_object_ids()
+ if panel.objmodel.get_object_group_id(panel.objmodel[uid]) == dup_group_id
+ ]
+ dup_titles = [panel.objmodel[uid].title for uid in dup_ids]
+
+ def _suffix(title: str) -> str:
+ parts = title.split("|", 1)
+ return parts[1].strip() if len(parts) > 1 else title.strip()
+
+ orig_suffixes = [_suffix(t) for t in original_titles]
+ dup_suffixes = [_suffix(t) for t in dup_titles]
+ clonable = orig_suffixes[: len(dup_suffixes)]
+ assert clonable == dup_suffixes
+
+
+# ---------------------------------------------------------------------------
+# 7) Stepping + selection sync
+# ---------------------------------------------------------------------------
+
+
+def test_history_stepping_and_selection_sync():
+ """Step-prev / step-next navigation + tree-to-panel selection sync."""
+ # --- scenario: step_next walks forward through current session ---
+ with datalab_test_app_context() as win:
+ _panel, history = _record_three_action_session(win)
+ sessions = history.history_sessions
+ actions = sessions[-1].actions
+ assert len(actions) >= 2
+ history.tree.clearSelection()
+ for action in actions:
+ history.step_next()
+ current = history.tree.currentItem()
+ assert current is not None
+ assert current.data(0, QC.Qt.UserRole) == action.uuid
+ # End -> no-op.
+ last_uuid = history.tree.currentItem().data(0, QC.Qt.UserRole)
+ history.step_next()
+ assert history.tree.currentItem().data(0, QC.Qt.UserRole) == last_uuid
+
+ # --- scenario: step_prev walks backward through current session ---
+ _select_tree_item_for(history, actions[-1])
+ for expected in reversed(actions[:-1]):
+ history.step_prev()
+ current = history.tree.currentItem()
+ assert current.data(0, QC.Qt.UserRole) == expected.uuid
+ first_uuid = history.tree.currentItem().data(0, QC.Qt.UserRole)
+ history.step_prev()
+ assert history.tree.currentItem().data(0, QC.Qt.UserRole) == first_uuid
+
+ # --- scenario: step button enabled state reflects position ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ prev_btn = history.step_prev_action
+ next_btn = history.step_next_action
+ history.update_actions_state()
+ assert not prev_btn.isEnabled()
+ assert not next_btn.isEnabled()
+ _panel, history = _record_three_action_session(win)
+ sessions = history.history_sessions
+ actions = sessions[-1].actions
+ prev_btn = history.step_prev_action
+ next_btn = history.step_next_action
+ _select_tree_item_for(history, actions[0])
+ assert not prev_btn.isEnabled()
+ assert next_btn.isEnabled()
+ if len(actions) >= 3:
+ _select_tree_item_for(history, actions[1])
+ assert prev_btn.isEnabled()
+ assert next_btn.isEnabled()
+ _select_tree_item_for(history, actions[-1])
+ assert prev_btn.isEnabled()
+ assert not next_btn.isEnabled()
+
+ # --- scenario: selecting a compute action selects its output ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ src_uuid = get_uuid(panel.objmodel.get_object_from_number(1))
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.normalize, sigima.params.NormalizeParam.create(method="maximum")
+ )
+ out_uuid = get_uuid(panel.objmodel.get_object_from_number(2))
+ norm_entry = history[len(history)]
+ assert norm_entry.func_name == "normalize"
+ assert src_uuid in norm_entry.state.selection.get("signal", [])
+ panel.objview.select_objects([src_uuid])
+ _select_tree_item_for(history, norm_entry)
+ assert panel.objview.get_sel_object_uuids() == [out_uuid]
+
+ # --- scenario: deleted output -> selection falls back to input ---
+ panel.objview.select_objects([2])
+ panel.remove_object(force=True)
+ assert len(panel.objmodel) == 1
+ panel.objview.select_groups([1])
+ _select_tree_item_for(history, norm_entry)
+ assert panel.objview.get_sel_object_uuids() == [src_uuid]
+
+
+# ---------------------------------------------------------------------------
+# 8) Processing-tab edit propagation + restore-selection-only
+# ---------------------------------------------------------------------------
+
+
+def test_history_edit_in_tree():
+ """Processing-tab parameter edits propagate into the matching action."""
+ # --- scenario: edit updates current session action and refreshes html ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ param = sigima.params.GaussianParam.create(sigma=1.5)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.gaussian_filter, param)
+ result_obj = panel.objmodel.get_object_from_number(2)
+ action = history[len(history)]
+ assert action.func_name == "gaussian_filter"
+ assert action.kwargs["param"].sigma == 1.5
+ html_before = action.description_html
+ panel.objview.select_objects([2])
+ assert panel.objprop.setup_processing_tab(result_obj, reset_params=False)
+ editor = panel.objprop.processing_param_editor
+ assert editor is not None
+ editor.dataset.sigma = 3.5
+ report = panel.objprop.apply_processing_parameters(
+ result_obj, interactive=False
+ )
+ assert report.success
+ assert action.kwargs["param"].sigma == 3.5
+ html_after = action.description_html
+ assert html_before != html_after
+ assert "3.5" in html_after
+ win.historypanel.refresh_action(action)
+ editor.dataset.sigma = 7.0
+ assert action.kwargs["param"].sigma == 3.5
+
+ # --- scenario: edit does NOT touch a past session ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.0)
+ )
+ past_action = history[len(history)]
+ assert past_action.kwargs["param"].sigma == 1.0
+ win.reset_all()
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=2.0)
+ )
+ new_obj = panel.objmodel.get_object_from_number(2)
+ new_action = history[len(history)]
+ assert new_action is not past_action
+ found = history.find_action_for_output(get_uuid(new_obj), "gaussian_filter")
+ assert found is new_action and found is not past_action
+ panel.objview.select_objects([2])
+ assert panel.objprop.setup_processing_tab(new_obj, reset_params=False)
+ editor = panel.objprop.processing_param_editor
+ assert editor is not None
+ editor.dataset.sigma = 4.0
+ report = panel.objprop.apply_processing_parameters(new_obj, interactive=False)
+ assert report.success
+ assert new_action.kwargs["param"].sigma == 4.0
+ assert past_action.kwargs["param"].sigma == 1.0
+
+ # --- scenario: restore selection only (replay=False) ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ src_uuid = get_uuid(panel.objmodel.get_object_from_number(1))
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ deriv_entry = history[len(history)]
+ derived = panel.objmodel.get_object_from_number(2)
+ panel.objview.set_current_object(derived)
+ derived_uuid = get_uuid(derived)
+ assert panel.objview.get_sel_object_uuids() == [derived_uuid]
+ _select_tree_item_for(history, deriv_entry)
+ n_before = len(panel.objmodel)
+ history.replay_restore_actions(replay=False, restore_selection=True)
+ assert len(panel.objmodel) == n_before
+ assert panel.objview.get_sel_object_uuids() == [src_uuid]
+
+
+# ---------------------------------------------------------------------------
+# 9) Cascade recompute
+# ---------------------------------------------------------------------------
+
+
+def test_history_cascade_recompute():
+ """Downstream detection + cascade recompute + duplicated-session cascade."""
+ # --- scenario: downstream detection ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ action_a, action_b, action_c, _ob, _oc = _build_cascade_chain(panel, history)
+ downstream = history.get_downstream_actions(action_a)
+ assert downstream == [action_b, action_c]
+ assert not history.get_downstream_actions(action_c)
+ assert history.get_downstream_actions(action_b) == [action_c]
+
+ # --- scenario: cascade recompute updates downstream outputs in place ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ panel = win.signalpanel
+ action_a, action_b, action_c, output_b, output_c = _build_cascade_chain(
+ panel, history
+ )
+ uuid_b = get_uuid(output_b)
+ uuid_c = get_uuid(output_c)
+ data_b_before = output_b.xydata.copy()
+ data_c_before = output_c.xydata.copy()
+ n_objects_before = len(panel.objmodel)
+ result_obj_a = panel.objmodel.get_object_from_number(2)
+ panel.objview.select_objects([2])
+ assert panel.objprop.setup_processing_tab(result_obj_a, reset_params=False)
+ editor = panel.objprop.processing_param_editor
+ assert editor is not None
+ editor.dataset.sigma = 6.0
+ report = panel.objprop.apply_processing_parameters(
+ result_obj_a, interactive=False
+ )
+ assert report.success
+ assert action_a.kwargs["param"].sigma == 6.0
+ assert len(panel.objmodel) == n_objects_before
+ assert get_uuid(panel.objmodel[uuid_b]) == uuid_b
+ assert get_uuid(panel.objmodel[uuid_c]) == uuid_c
+ assert not np.array_equal(panel.objmodel[uuid_b].xydata, data_b_before)
+ assert not np.array_equal(panel.objmodel[uuid_c].xydata, data_c_before)
+ for a in (action_a, action_b, action_c):
+ assert a.is_stale is False
+
+ # --- scenario: play on stale action triggers cascade ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ action_a, action_b, _ac, output_b, _oc = _build_cascade_chain(panel, history)
+ uuid_b = get_uuid(output_b)
+ output_b.xydata = output_b.xydata * 0.0
+ tampered = output_b.xydata.copy()
+ action_a.is_stale = True
+ _select_tree_item_for(history, action_a)
+ history.replay_restore_actions(replay=True, restore_selection=False)
+ assert action_a.is_stale is False
+ assert action_b.is_stale is False
+ assert not np.array_equal(panel.objmodel[uuid_b].xydata, tampered)
+
+ # --- scenario: cascade in a duplicated session that is NOT [-1] ---
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ panel = win.signalpanel
+ action_a, _ab, _ac, _ob, output_c = _build_cascade_chain(panel, history)
+ uuid_c_orig = get_uuid(output_c)
+ data_c_orig = output_c.xydata.copy()
+ panel.objview.select_objects([4])
+ panel.processor.run_feature(sips.derivative)
+ sessions = history.history_sessions
+ s1 = sessions[0]
+ history.create_new_session()
+ panel.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([6])
+ panel.processor.run_feature(sips.derivative)
+ assert len(sessions) == 2
+ _select_tree_session(history, s1)
+ history.duplicate_selected_entries()
+ assert len(sessions) == 3
+ dup_session = sessions[1]
+ assert sessions[-1] is not dup_session
+ dup_action_a = next(
+ a for a in dup_session.actions if a.func_name == action_a.func_name
+ )
+ dup_action_c = next(
+ a for a in dup_session.actions if a.func_name == "moving_average"
+ )
+ dup_output_c_uuid = history.action_output_uuid(dup_action_c)
+ assert dup_output_c_uuid is not None
+ data_dup_c_before = panel.objmodel[dup_output_c_uuid].xydata.copy()
+ dup_result_obj_a_uuid = history.action_output_uuid(dup_action_a)
+ assert dup_result_obj_a_uuid is not None
+ dup_result_obj_a = panel.objmodel[dup_result_obj_a_uuid]
+ panel.objview.select_objects([dup_result_obj_a_uuid])
+ assert panel.objprop.setup_processing_tab(dup_result_obj_a, reset_params=False)
+ editor = panel.objprop.processing_param_editor
+ assert editor is not None
+ editor.dataset.sigma = 10.0
+ report = panel.objprop.apply_processing_parameters(
+ dup_result_obj_a, interactive=False
+ )
+ assert report.success
+ assert not np.array_equal(
+ panel.objmodel[dup_output_c_uuid].xydata, data_dup_c_before
+ )
+ assert np.array_equal(panel.objmodel[uuid_c_orig].xydata, data_c_orig)
+
+
+# ---------------------------------------------------------------------------
+# 10) Chain reconnect after object deletion
+# ---------------------------------------------------------------------------
+
+
+def test_history_chain_reconnect():
+ """Deleting an intermediate result rewires downstream actions to its source."""
+ with datalab_test_app_context() as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel = win.signalpanel
+ panel.add_object(create_paracetamol_signal())
+ src_uuid = get_uuid(panel.objmodel.get_object_from_number(1))
+
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5)
+ )
+ s002_uuid = get_uuid(panel.objmodel.get_object_from_number(2))
+ action_gaussian = history[len(history)]
+ assert action_gaussian.func_name == "gaussian_filter"
+
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.derivative)
+ action_deriv = history[len(history)]
+ assert action_deriv.func_name == "derivative"
+ assert s002_uuid in action_deriv.state.selection.get("signal", [])
+
+ panel.objview.select_objects([2])
+ panel.remove_object(force=True)
+ assert len(panel.objmodel) == 2
+
+ reconnected_uuids = action_deriv.state.selection.get("signal", [])
+ assert s002_uuid not in reconnected_uuids
+ assert src_uuid in reconnected_uuids
diff --git a/datalab/tests/features/common/interactive_processing_test.py b/datalab/tests/features/common/interactive_processing_test.py
index 9ea1351c..705abec1 100644
--- a/datalab/tests/features/common/interactive_processing_test.py
+++ b/datalab/tests/features/common/interactive_processing_test.py
@@ -164,6 +164,10 @@ def test_recompute():
filtered_sig = panel.objview.get_current_object()
original_data = filtered_sig.y.copy()
+ # In-place recompute requires History panel edit mode (otherwise a
+ # new object is created instead of mutating the existing one).
+ win.historypanel.toggle_edit_mode(True)
+
# Recompute with different input signal data
constant = 1.23098765
signal.y += constant
@@ -402,6 +406,10 @@ def test_apply_processing_parameters_signal():
# Change constant from 5.0 to 15.0
editor.dataset.value = v1 = 15.0
+ # In-place update requires History panel edit mode (otherwise a new
+ # object is created instead of mutating the existing one).
+ win.historypanel.toggle_edit_mode(True)
+
# Apply the new processing parameters
report = objprop.apply_processing_parameters()
@@ -463,6 +471,10 @@ def test_apply_processing_parameters_image():
# Change constant from 7.0 to 20.0
editor.dataset.value = v1 = 20.0
+ # In-place update requires History panel edit mode (otherwise a new
+ # object is created instead of mutating the existing one).
+ win.historypanel.toggle_edit_mode(True)
+
# Apply the new processing parameters
report = objprop.apply_processing_parameters()
@@ -658,6 +670,10 @@ def test_cross_panel_image_to_signal():
editor.dataset.x0 = 40
editor.dataset.y0 = 40
+ # In-place update + in-place recompute require History panel edit
+ # mode (otherwise new objects are created instead of mutating).
+ win.historypanel.toggle_edit_mode(True)
+
# Apply the new processing parameters
report = signal_panel.objprop.apply_processing_parameters()
@@ -1065,6 +1081,10 @@ def test_roi_mask_invalidation_on_processing_change():
editor.dataset.sx = 4
editor.dataset.sy = 4
+ # In-place update requires History panel edit mode (otherwise a new
+ # object is created instead of mutating the existing one).
+ win.historypanel.toggle_edit_mode(True)
+
# Apply the new processing parameters
report = objprop.apply_processing_parameters(binned)
assert report.success
diff --git a/datalab/widgets/historydescription.py b/datalab/widgets/historydescription.py
new file mode 100644
index 00000000..e2ca7945
--- /dev/null
+++ b/datalab/widgets/historydescription.py
@@ -0,0 +1,92 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Collapsible description widget used by the History panel."""
+
+from __future__ import annotations
+
+import html
+
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+
+
+class CollapsibleDescriptionWidget(QW.QWidget):
+ """Compact, expandable cell widget for the history Description column.
+
+ Shows a single-line summary by default; a chevron toggle reveals the full
+ HTML description (mirroring the *Properties* tab rendering).
+ """
+
+ toggled = QC.Signal(bool)
+
+ def __init__(
+ self,
+ summary: str,
+ html_text: str,
+ expanded: bool = False,
+ parent: QW.QWidget | None = None,
+ ) -> None:
+ super().__init__(parent)
+ self._summary = summary
+ self._html = html_text
+ self._expanded = expanded
+
+ self._toggle = QW.QToolButton(self)
+ self._toggle.setAutoRaise(True)
+ self._toggle.setCheckable(True)
+ self._toggle.setFocusPolicy(QC.Qt.NoFocus)
+ self._toggle.setArrowType(QC.Qt.RightArrow)
+ self._toggle.setToolTip(_("Show details"))
+
+ self._label = QW.QLabel(self)
+ self._label.setTextFormat(QC.Qt.RichText)
+ self._label.setWordWrap(True)
+ self._label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse)
+ self._label.setAlignment(QC.Qt.AlignTop | QC.Qt.AlignLeft)
+
+ layout = QW.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(2)
+ layout.addWidget(self._toggle, 0, QC.Qt.AlignTop)
+ layout.addWidget(self._label, 1)
+
+ # Hide the toggle when there is nothing more to show than the summary.
+ if not self._html or self.html_matches_summary():
+ self._toggle.setVisible(False)
+
+ self._toggle.toggled.connect(self.on_toggled)
+ self.refresh_widget()
+
+ def html_matches_summary(self) -> bool:
+ """Return True when the HTML rendering would not add information."""
+ return self._html.strip() == html.escape(self._summary).strip()
+
+ def on_toggled(self, checked: bool) -> None:
+ """Handle the expand/collapse toggle being toggled."""
+ self._expanded = checked
+ self.refresh_widget()
+ self.toggled.emit(checked)
+
+ def refresh_widget(self) -> None:
+ """Refresh the widget contents to match the current expanded state."""
+ if self._expanded:
+ self._toggle.setArrowType(QC.Qt.DownArrow)
+ self._toggle.setToolTip(_("Hide details"))
+ self._label.setText(self._html or html.escape(self._summary))
+ else:
+ self._toggle.setArrowType(QC.Qt.RightArrow)
+ self._toggle.setToolTip(_("Show details"))
+ self._label.setText(html.escape(self._summary))
+ self.updateGeometry()
+
+ def is_expanded(self) -> bool:
+ """Return current expanded state."""
+ return self._expanded
+
+ def set_expanded(self, expanded: bool) -> None:
+ """Programmatically set the expanded state."""
+ if expanded == self._expanded:
+ return
+ self._toggle.setChecked(expanded)
diff --git a/datalab/widgets/historytree.py b/datalab/widgets/historytree.py
new file mode 100644
index 00000000..6591285e
--- /dev/null
+++ b/datalab/widgets/historytree.py
@@ -0,0 +1,249 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""History tree widget used by the History panel."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from guidata.configtools import get_icon
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.history import HistoryAction, HistorySession
+from datalab.widgets.historydescription import CollapsibleDescriptionWidget
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+
+
+class HistoryTree(QW.QTreeWidget):
+ """Tree widget for the history panel"""
+
+ DESCRIPTION_COLUMN = 2
+ COMPATIBILITY_ROLE = QC.Qt.UserRole + 1
+
+ def __init__(self, parent: QW.QWidget) -> None:
+ """Create a new history tree widget"""
+ super().__init__(parent)
+ self.setHeaderLabels([_("Title"), _("Date and time"), _("Description")])
+ self.setContextMenuPolicy(QC.Qt.CustomContextMenu)
+ self.setSelectionMode(QW.QAbstractItemView.ContiguousSelection)
+ self.setUniformRowHeights(False)
+ header = self.header()
+ header.setSectionResizeMode(self.DESCRIPTION_COLUMN, QW.QHeaderView.Stretch)
+ # Per-action expanded state, preserved across repopulate (delete/replay).
+ self.__expanded_state: dict[str, bool] = {}
+
+ def on_description_toggled(self, uuid: str, expanded: bool) -> None:
+ """Remember the expanded state of a description cell."""
+ self.__expanded_state[uuid] = expanded
+ # Force the tree to recompute row heights now that the label content
+ # has changed.
+ self.scheduleDelayedItemsLayout()
+
+ def install_description_widget(
+ self, item: QW.QTreeWidgetItem, action: HistoryAction
+ ) -> None:
+ """Attach the collapsible description widget to ``item`` (column 2).
+
+ The item must already be inserted in the tree before calling this.
+ """
+ expanded = self.__expanded_state.get(action.uuid, False)
+ widget = CollapsibleDescriptionWidget(
+ action.description_summary,
+ action.description_html,
+ expanded=expanded,
+ parent=self,
+ )
+ widget.toggled.connect(
+ lambda checked, uuid=action.uuid: self.on_description_toggled(uuid, checked)
+ )
+ # Clear any text the item may carry for that column to avoid double
+ # rendering behind the widget.
+ item.setText(self.DESCRIPTION_COLUMN, "")
+ self.setItemWidget(item, self.DESCRIPTION_COLUMN, widget)
+
+ @classmethod
+ def action_to_tree_item(cls, action: HistoryAction) -> QW.QTreeWidgetItem:
+ """Convert an action to a tree item
+
+ Args:
+ action: Action to convert
+
+ Returns:
+ QW.QTreeWidgetItem: Tree item
+ """
+ # Description column is left empty: a CollapsibleDescriptionWidget is
+ # installed by ``HistoryTree`` once the item is inserted in the tree.
+ item = QW.QTreeWidgetItem([action.title, action.dtstr, ""])
+ item.setData(0, QC.Qt.UserRole, action.uuid)
+ item.setData(0, cls.COMPATIBILITY_ROLE, True)
+ return item
+
+ def update_compatibility_states(
+ self, history_sessions: list[HistorySession], mainwindow: DLMainWindow
+ ) -> None:
+ """Update action item visual state from workspace compatibility."""
+ default_brush = QG.QBrush()
+ disabled_brush = QG.QBrush(
+ self.palette().color(QG.QPalette.Disabled, QG.QPalette.Text)
+ )
+ compatible_tip = _("Action is compatible with the current workspace state.")
+ incompatible_tip = _(
+ "Action is not compatible with the current workspace state."
+ )
+ for i in range(self.topLevelItemCount()):
+ session_item = self.topLevelItem(i)
+ for j in range(session_item.childCount()):
+ child = session_item.child(j)
+ uuid = child.data(0, QC.Qt.UserRole)
+ action = self.get_action_from_uuid(uuid, history_sessions)
+ compatible = action.is_current_state_compatible(
+ mainwindow, restore_selection=True
+ )
+ child.setData(0, self.COMPATIBILITY_ROLE, compatible)
+ brush = default_brush if compatible else disabled_brush
+ icon = get_icon("apply.svg") if compatible else get_icon("delete.svg")
+ child.setIcon(0, icon)
+ for col in range(self.columnCount()):
+ child.setForeground(col, brush)
+ child.setToolTip(
+ col, compatible_tip if compatible else incompatible_tip
+ )
+
+ def forget_orphan_expanded_states(
+ self, history_sessions: list[HistorySession]
+ ) -> None:
+ """Drop expanded-state entries for actions that no longer exist."""
+ live_uuids = {
+ action.uuid for session in history_sessions for action in session.actions
+ }
+ self.__expanded_state = {
+ uuid: state
+ for uuid, state in self.__expanded_state.items()
+ if uuid in live_uuids
+ }
+
+ def populate_tree(self, history_sessions: list[HistorySession]) -> None:
+ """Populate the history tree widget
+
+ Args:
+ history_sessions: List of history sessions
+ """
+ self.forget_orphan_expanded_states(history_sessions)
+ self.clear()
+ for session in history_sessions:
+ ritem = QW.QTreeWidgetItem([session.title, session.dtstr])
+ ritem.setData(0, self.COMPATIBILITY_ROLE, True)
+ self.addTopLevelItem(ritem)
+ for action in session.actions:
+ child = self.action_to_tree_item(action)
+ ritem.addChild(child)
+ self.install_description_widget(child, action)
+ self.expandAll()
+ for col in (0, 1):
+ self.resizeColumnToContents(col)
+
+ def rearrange_tree(self) -> None:
+ """Rearrange the history tree widget"""
+ self.expandAll()
+ for col in (0, 1):
+ self.resizeColumnToContents(col)
+
+ def add_action_to_tree(self, action: HistoryAction) -> None:
+ """Add an action to the history tree widget
+
+ Args:
+ action: Action to add
+ """
+ item = self.action_to_tree_item(action)
+ ritem = self.topLevelItem(self.topLevelItemCount() - 1)
+ ritem.addChild(item)
+ self.install_description_widget(item, action)
+
+ def refresh_action_item(self, action: HistoryAction) -> None:
+ """Refresh the tree item corresponding to ``action``.
+
+ Re-installs the description widget so it reflects the current
+ ``action.kwargs`` (e.g. after the user edited a ``param`` via the
+ Processing tab of the Signal/Image panel). Also applies a light
+ orange background when ``action.is_stale`` is True, to signal that
+ the action is currently being recomputed in a cascade.
+ """
+ target_uuid = action.uuid
+ stale_brush = QG.QBrush(QG.QColor(255, 220, 150)) # light orange
+ normal_brush = QG.QBrush()
+ iterator = QW.QTreeWidgetItemIterator(self)
+ while iterator.value():
+ item = iterator.value()
+ if item.data(0, QC.Qt.UserRole) == target_uuid:
+ # Remove and re-install the collapsible description widget so
+ # it reflects the mutated ``action.kwargs``.
+ self.removeItemWidget(item, self.DESCRIPTION_COLUMN)
+ self.install_description_widget(item, action)
+ brush = stale_brush if action.is_stale else normal_brush
+ for col in range(self.columnCount()):
+ item.setBackground(col, brush)
+ self.scheduleDelayedItemsLayout()
+ return
+ iterator += 1
+
+ def get_action_from_uuid(
+ self, uuid: str, history_sessions: list[HistorySession]
+ ) -> HistoryAction:
+ """Get the action from its UUID
+
+ Args:
+ uuid: Action UUID
+ history_sessions: List of history sessions
+
+ Returns:
+ HistoryAction: Action
+ """
+ for session in history_sessions:
+ for action in session.actions:
+ if action.uuid == uuid:
+ return action
+ raise ValueError("Action not found")
+
+ def get_selected_actions_or_sessions(
+ self, history_sessions: list[HistorySession]
+ ) -> list[HistoryAction | HistorySession]:
+ """Get the selected actions or sessions
+
+ Args:
+ history_sessions: List of history sessions
+
+ Returns:
+ list[HistoryAction | HistorySession]: List of selected actions or sessions
+ """
+ selected: list[HistoryAction | HistorySession] = []
+ for item in self.selectedItems():
+ if item.parent() is None:
+ index = self.indexOfTopLevelItem(item)
+ selected.append(history_sessions[index])
+ else:
+ uuid = item.data(0, QC.Qt.UserRole)
+ selected.append(self.get_action_from_uuid(uuid, history_sessions))
+ return selected
+
+ def get_selected_actions(
+ self, history_sessions: list[HistorySession]
+ ) -> list[HistoryAction]:
+ """Get the selected actions
+
+ Args:
+ history_sessions: List of history sessions
+
+ Returns:
+ list[HistoryAction]: List of selected actions
+ """
+ selected: list[HistoryAction] = []
+ for item in self.selectedItems():
+ if item.parent() is not None:
+ uuid = item.data(0, QC.Qt.UserRole)
+ selected.append(self.get_action_from_uuid(uuid, history_sessions))
+ return selected
diff --git a/datalab/widgets/workspacestate_widget.py b/datalab/widgets/workspacestate_widget.py
new file mode 100644
index 00000000..50b81c9e
--- /dev/null
+++ b/datalab/widgets/workspacestate_widget.py
@@ -0,0 +1,82 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Workspace state display widget used by the History panel."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+
+if TYPE_CHECKING:
+ from datalab.history import WorkspaceState
+
+
+class WorkspaceStateWidget(QW.QWidget):
+ """Side-by-side tables showing the workspace state captured by a history action.
+
+ Left table: signals (title + data shape).
+ Right table: images (title + data shape/dimensions).
+ """
+
+ def __init__(self, parent: QW.QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._signal_table = QW.QTableWidget(0, 2, self)
+ self._signal_table.setHorizontalHeaderLabels([_("Signal"), _("Shape")])
+ self._signal_table.horizontalHeader().setStretchLastSection(True)
+ self._signal_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers)
+ self._signal_table.setSelectionMode(QW.QAbstractItemView.NoSelection)
+ self._signal_table.verticalHeader().hide()
+
+ self._image_table = QW.QTableWidget(0, 2, self)
+ self._image_table.setHorizontalHeaderLabels([_("Image"), _("Dimensions")])
+ self._image_table.horizontalHeader().setStretchLastSection(True)
+ self._image_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers)
+ self._image_table.setSelectionMode(QW.QAbstractItemView.NoSelection)
+ self._image_table.verticalHeader().hide()
+
+ splitter = QW.QSplitter(QC.Qt.Horizontal, self)
+ splitter.addWidget(self._signal_table)
+ splitter.addWidget(self._image_table)
+ layout = QW.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(splitter)
+
+ def update_from_state(self, state: WorkspaceState | None) -> None:
+ """Populate tables from a WorkspaceState."""
+ self._signal_table.setRowCount(0)
+ self._image_table.setRowCount(0)
+ if state is None:
+ return
+ self.populate_table(self._signal_table, state, "signal")
+ self.populate_table(self._image_table, state, "image")
+
+ @staticmethod
+ def populate_table(
+ table: QW.QTableWidget, state: WorkspaceState, panel_key: str
+ ) -> None:
+ """Fill a table from the state for a given panel key."""
+ titles = state.titles.get(panel_key, [])
+ shapes = state.states.get(panel_key, [])
+ metadata = state.object_metadata.get(panel_key, {})
+ uuids = state.selection.get(panel_key, [])
+ # Use metadata keyed by UUID when available
+ rows: list[tuple[str, str]] = []
+ for i, uuid in enumerate(uuids):
+ title = titles[i] if i < len(titles) else uuid[:8]
+ meta = metadata.get(uuid, {})
+ shape = meta.get("shape")
+ if shape is not None:
+ shape_str = " × ".join(str(s) for s in shape)
+ elif i < len(shapes):
+ shape_str = shapes[i]
+ else:
+ shape_str = "—"
+ rows.append((title, shape_str))
+ table.setRowCount(len(rows))
+ for row_idx, (title, shape_str) in enumerate(rows):
+ table.setItem(row_idx, 0, QW.QTableWidgetItem(title))
+ table.setItem(row_idx, 1, QW.QTableWidgetItem(shape_str))
diff --git a/doc/features/common/historypanel.rst b/doc/features/common/historypanel.rst
new file mode 100644
index 00000000..7f1ecf59
--- /dev/null
+++ b/doc/features/common/historypanel.rst
@@ -0,0 +1,257 @@
+.. _historypanel:
+
+History Panel
+=============
+
+.. meta::
+ :description: History Panel in DataLab, the open-source scientific data analysis and visualization platform
+ :keywords: DataLab, history, record, replay, session, scientific, data, analysis, visualization, platform
+
+Overview
+--------
+
+The "History Panel" records the sequence of actions performed by the user on
+signals and images, organized into **sessions**. Each session is a chronological
+list of either:
+
+- **UI actions** (creating a new signal, removing selected objects, saving the
+ workspace to HDF5, ...), or
+- **computations** (FFT, average, Gaussian fit, ...) dispatched through the
+ Sigima processor.
+
+A recorded session can be:
+
+- **Replayed** in validation mode, without adding new signal/image outputs to
+ the workspace;
+- **Duplicated and applied**, to create an explicit comparison branch with new
+ outputs in the signal/image panels;
+- **Restored to a given selection state** without re-executing anything, to
+ quickly jump back to a previous working context;
+- **Saved to a standalone history file** (``.dlhist``) or **embedded in the
+ workspace** when saving to HDF5, so that the full processing chain travels
+ with the data.
+
+.. figure:: ../../images/shots/history_panel.png
+ :align: center
+ :alt: History Panel
+
+ The History Panel after recording a representative session: create three
+ signals (Voigt, Lorentzian, Lorentzian), remove one of them, create a
+ Gaussian signal, compute the average, add Gaussian noise to the result
+ and run a Gaussian fit.
+
+Toolbar
+-------
+
+The toolbar at the top of the panel exposes the following actions:
+
+.. |record| image:: ../../../datalab/data/icons/record.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |open_history| image:: ../../../datalab/data/icons/io/fileopen_h5.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |save_history| image:: ../../../datalab/data/icons/io/filesave_h5.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |replay| image:: ../../../datalab/data/icons/replay.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |restore_selection| image:: ../../../datalab/data/icons/restore_selection.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |edit_mode| image:: ../../../datalab/data/icons/edit_mode.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |duplicate| image:: ../../../datalab/data/icons/edit/duplicate.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |step_prev| image:: ../../../datalab/data/icons/libre-gui-arrow-left.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |step_next| image:: ../../../datalab/data/icons/libre-gui-arrow-right.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |delete| image:: ../../../datalab/data/icons/edit/delete.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |generate_macro| image:: ../../../datalab/data/icons/console.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |remove_incompatible| image:: ../../../datalab/data/icons/edit/delete_all.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+- |record| **Record mode**: toggle the recording of new actions. When off, no
+ new entry is added to the history (existing sessions are preserved).
+- |open_history| **Open history file**: load recorded sessions from a standalone
+ ``.dlhist`` file.
+- |save_history| **Save history file**: save the current recorded sessions to a
+ standalone ``.dlhist`` file.
+- |replay| **Replay**: validate/replay the selected action (or the whole
+ session if a session row is selected) without changing the current workspace
+ selection beforehand and without adding new outputs to the signal/image
+ panels.
+- |restore_selection| **Restore selection**: only re-select the objects that
+ were selected when the action was originally executed; no computation is
+ re-run.
+- |edit_mode| **Edit mode**: when on, replaying a computation opens the
+ parameters dialog so the user can tweak the parameters before re-running.
+ When replaying a *whole session*, the parameter dialogs open in a
+ **read-only** mode — all fields are shown with their recorded values but
+ cannot be edited.
+- |duplicate| **Duplicate**: copy the selected action or session into a new
+ history session. The copied parameters are independent from the original
+ record.
+- |generate_macro| **Generate macro**: generate a Python macro script from the
+ selected actions (or all actions if nothing is selected). The generated script
+ is copied to the clipboard.
+- |remove_incompatible| **Remove incompatible**: remove all actions whose
+ workspace state is no longer compatible with the current workspace. A
+ confirmation dialog shows how many actions will be removed.
+- |delete| **Delete**: remove the selected actions or sessions from the
+ history.
+- |step_prev| **Previous step**: select the preceding action in the current
+ session (keyboard shortcut: :kbd:`Ctrl+Left`).
+- |step_next| **Next step**: select the following action in the current
+ session (keyboard shortcut: :kbd:`Ctrl+Right`).
+
+.. note::
+
+ Double-clicking on an action row in the tree is equivalent to **Replay**.
+
+Tree view
+---------
+
+The tree view organizes recorded actions into expandable sessions:
+
+- Each top-level row is a **session**, automatically created when recording is
+ enabled and a new application context is started.
+- Each child row is an **action**, with its title, date/time and a description
+ summarising the parameters (for computations) or the call (for UI actions).
+
+The selection of one or several rows drives which actions are targeted by the
+toolbar buttons.
+
+Actions that are not compatible with the current workspace state (for example
+because a referenced object identifier no longer exists, or because its data
+shape changed) are shown with a disabled foreground and an explanatory tooltip.
+They cannot be replayed until the workspace matches the recorded state again.
+
+Workspace state display
+-----------------------
+
+Below the action tree, a split-view widget shows the **workspace state**
+captured at the time of the selected action:
+
+- **Left table**: lists the signals that were selected, with their data shape.
+- **Right table**: lists the images that were selected, with their dimensions.
+
+This information helps the user understand the context in which each action
+was originally executed and diagnose compatibility issues when replaying
+sessions on a different workspace.
+
+Session replay across workspaces
+--------------------------------
+
+A full session can be replayed on a workspace that no longer contains the
+objects originally referenced by the recorded actions -- typically after
+loading a saved session into a fresh workspace. In that case, the panel
+**remaps the recorded object identifiers** to the newly-created ones on the
+fly:
+
+- UI actions creating new objects (e.g. *New signal*) enqueue the freshly
+ created identifiers;
+- subsequent computations claim the identifiers they need from that queue,
+ in the same order as the original recording;
+- UI actions removing objects keep the queue in sync with the live workspace
+ contents, so chained creation/removal sequences replay correctly.
+
+This makes it possible, for instance, to record a full processing chain on
+one dataset, save it, then re-apply the exact same chain on a different but
+structurally identical input.
+
+Persistence
+-----------
+
+The history can be persisted in two complementary ways:
+
+- **Embedded in the workspace**: when the workspace is saved to HDF5
+ (``File > Save to HDF5 file``), the History Panel content is automatically
+ saved alongside the signals and images. Reloading the workspace restores
+ the recorded sessions.
+- **Standalone history file** (``.dlhist``): the file embeds both the
+ recorded sessions **and** all signal/image objects referenced by those
+ sessions. This makes the file fully self-contained:
+
+ - Opening a ``.dlhist`` into an **empty workspace** loads sessions and
+ objects directly, restoring the workspace to its recorded state.
+ - Opening a ``.dlhist`` into a **non-empty workspace** creates new
+ signal/image groups for the imported objects (with remapped identifiers
+ to avoid collisions) and appends new history sessions that reference
+ those fresh identifiers.
+
+.. warning::
+
+ Replaying a session that depends on external files (e.g. opening a
+ dataset from disk) will only succeed if those files are still available at
+ the same locations as when the session was recorded.
+
+Chain reconnection on deletion
+-------------------------------
+
+When a result object is deleted from the **signal or image panel** (not
+from the History Panel tree), and that object was produced by a recorded
+processing step, the History Panel automatically reconnects the processing
+chain:
+
+- All downstream steps that consumed the deleted object are rewired to use
+ the source of the deleted step as their new input.
+- For ``2_to_1`` operations (e.g. *difference*), the first source is used
+ for reconnection.
+- If no valid source can be determined (e.g. the source itself was already
+ deleted), a warning is displayed listing the unreconnectable operations,
+ but the deletion is allowed to proceed.
+
+This behaviour mirrors removing a link from a chain: the adjacent links
+reconnect to preserve the processing flow.
+
+.. note::
+
+ Reconnection is only triggered by deletions initiated from the
+ signal/image panels. Deleting an action directly from the History Panel
+ tree removes it and all subsequent actions in that session.
+
+Auto-recompute
+--------------
+
+.. note::
+
+ When a result object is selected in the signal/image panel and it has
+ processing parameters (i.e. was produced by a 1-to-1 computation), a
+ **Processing** tab appears in the Properties panel. Checking
+ **Auto-recompute on edit** in that tab will re-run the computation
+ automatically 300 ms after any parameter modification.
diff --git a/doc/features/index.rst b/doc/features/index.rst
index b05605bf..09254926 100644
--- a/doc/features/index.rst
+++ b/doc/features/index.rst
@@ -35,6 +35,7 @@ Overview & Common features
common/overview
common/settings
common/h5browser
+ common/historypanel
.. raw:: latex
diff --git a/doc/images/shots/history_panel.png b/doc/images/shots/history_panel.png
new file mode 100644
index 00000000..0ebd0e32
Binary files /dev/null and b/doc/images/shots/history_panel.png differ
diff --git a/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po
new file mode 100644
index 00000000..daf1be0d
--- /dev/null
+++ b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po
@@ -0,0 +1,231 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2023, DataLab Platform Developers
+# This file is distributed under the same license as the DataLab package.
+# FIRST AUTHOR , 2026.
+#
+msgid ""
+msgstr ""
+"Language: fr\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+msgid "History Panel in DataLab, the open-source scientific data analysis and visualization platform"
+msgstr "Panneau historique de DataLab, la plateforme open-source d'analyse et de visualisation de données scientifiques"
+
+msgid "DataLab, history, record, replay, session, scientific, data, analysis, visualization, platform"
+msgstr "DataLab, historique, enregistrement, rejeu, session, scientifique, données, analyse, visualisation, plateforme"
+
+msgid "History Panel"
+msgstr "Panneau historique"
+
+msgid "Overview"
+msgstr "Vue d'ensemble"
+
+msgid "The \"History Panel\" records the sequence of actions performed by the user on signals and images, organized into **sessions**. Each session is a chronological list of either:"
+msgstr "Le « Panneau historique » enregistre la séquence des actions effectuées par l'utilisateur sur les signaux et les images, organisée en **sessions**. Chaque session est une liste chronologique constituée soit :"
+
+msgid "**UI actions** (creating a new signal, removing selected objects, saving the workspace to HDF5, ...), or"
+msgstr "d'**actions de l'interface** (création d'un nouveau signal, suppression des objets sélectionnés, enregistrement de l'espace de travail au format HDF5, ...), soit"
+
+msgid "**computations** (FFT, average, Gaussian fit, ...) dispatched through the Sigima processor."
+msgstr "de **calculs** (FFT, moyenne, ajustement gaussien, ...) exécutés via le processeur Sigima."
+
+msgid "A recorded session can be:"
+msgstr "Une session enregistrée peut être :"
+
+msgid "**Replayed** in validation mode, without adding new signal/image outputs to the workspace;"
+msgstr "**rejouée** en mode validation, sans ajouter de nouvelles sorties signal/image à l'espace de travail ;"
+
+msgid "**Duplicated and applied**, to create an explicit comparison branch with new outputs in the signal/image panels;"
+msgstr "**dupliquée et appliquée**, afin de créer une branche de comparaison explicite avec de nouvelles sorties dans les panneaux signal/image ;"
+
+msgid "**Restored to a given selection state** without re-executing anything, to quickly jump back to a previous working context;"
+msgstr "**restaurée dans un état de sélection donné** sans rien réexécuter, afin de revenir rapidement à un contexte de travail antérieur ;"
+
+msgid "**Saved to a standalone history file** (``.dlhist``) or **embedded in the workspace** when saving to HDF5, so that the full processing chain travels with the data."
+msgstr "**sauvegardée dans un fichier d'historique autonome** (``.dlhist``) ou **intégrée à l'espace de travail** lors de l'enregistrement au format HDF5, de sorte que toute la chaîne de traitement accompagne les données."
+
+msgid "The History Panel after recording a representative session: create three signals (Voigt, Lorentzian, Lorentzian), remove one of them, create a Gaussian signal, compute the average, add Gaussian noise to the result and run a Gaussian fit."
+msgstr "Le Panneau historique après l'enregistrement d'une session représentative : création de trois signaux (Voigt, lorentzien, lorentzien), suppression de l'un d'eux, création d'un signal gaussien, calcul de la moyenne, ajout d'un bruit gaussien au résultat et ajustement par une gaussienne."
+
+msgid "Toolbar"
+msgstr "Barre d'outils"
+
+msgid "The toolbar at the top of the panel exposes the following actions:"
+msgstr "La barre d'outils en haut du panneau expose les actions suivantes :"
+
+msgid "|record| **Record mode**: toggle the recording of new actions. When off, no new entry is added to the history (existing sessions are preserved)."
+msgstr "|record| **Mode enregistrement** : active ou désactive l'enregistrement des nouvelles actions. Lorsqu'il est désactivé, aucune nouvelle entrée n'est ajoutée à l'historique (les sessions existantes sont conservées)."
+
+msgid "record"
+msgstr "record"
+
+msgid "|open_history| **Open history file**: load recorded sessions from a standalone ``.dlhist`` file."
+msgstr "|open_history| **Ouvrir un fichier d'historique** : charge des sessions enregistrées depuis un fichier ``.dlhist`` autonome."
+
+msgid "open_history"
+msgstr "open_history"
+
+msgid "|save_history| **Save history file**: save the current recorded sessions to a standalone ``.dlhist`` file."
+msgstr "|save_history| **Enregistrer le fichier d'historique** : enregistre les sessions actuellement enregistrées dans un fichier ``.dlhist`` autonome."
+
+msgid "save_history"
+msgstr "save_history"
+
+msgid "|replay| **Replay**: validate/replay the selected action (or the whole session if a session row is selected) without changing the current workspace selection beforehand and without adding new outputs to the signal/image panels."
+msgstr "|replay| **Rejouer** : valide/rejoue l'action sélectionnée (ou la session entière si une ligne de session est sélectionnée) sans modifier au préalable la sélection courante de l'espace de travail et sans ajouter de nouvelles sorties aux panneaux signal/image."
+
+msgid "replay"
+msgstr "replay"
+
+msgid "|restore_selection| **Restore selection**: only re-select the objects that were selected when the action was originally executed; no computation is re-run."
+msgstr "|restore_selection| **Restaurer la sélection** : ré-sélectionne uniquement les objets qui étaient sélectionnés lors de l'exécution initiale de l'action ; aucun calcul n'est ré-exécuté."
+
+msgid "restore_selection"
+msgstr "restore_selection"
+
+msgid "|edit_mode| **Edit mode**: when on, replaying a computation opens the parameters dialog so the user can tweak the parameters before re-running. When replaying a *whole session*, the parameter dialogs open in a **read-only** mode — all fields are shown with their recorded values but cannot be edited."
+msgstr "|edit_mode| **Mode édition** : lorsqu'il est activé, le rejeu d'un calcul ouvre la boîte de dialogue des paramètres afin que l'utilisateur puisse ajuster les paramètres avant de relancer. Lors du rejeu d'une *session entière*, les boîtes de dialogue des paramètres s'ouvrent en mode **lecture seule** — tous les champs affichent les valeurs enregistrées mais ne peuvent pas être édités."
+
+msgid "edit_mode"
+msgstr "edit_mode"
+
+msgid "|duplicate| **Duplicate**: copy the selected action or session into a new history session. The copied parameters are independent from the original record."
+msgstr "|duplicate| **Dupliquer** : copie l'action ou la session sélectionnée dans une nouvelle session d'historique. Les paramètres copiés sont indépendants de l'enregistrement d'origine."
+
+msgid "duplicate"
+msgstr "duplicate"
+
+msgid "|generate_macro| **Generate macro**: generate a Python macro script from the selected actions (or all actions if nothing is selected). The generated script is copied to the clipboard."
+msgstr "|generate_macro| **Générer une macro** : génère un script macro Python à partir des actions sélectionnées (ou de toutes les actions si rien n'est sélectionné). Le script généré est copié dans le presse-papiers."
+
+msgid "generate_macro"
+msgstr "generate_macro"
+
+msgid "|remove_incompatible| **Remove incompatible**: remove all actions whose workspace state is no longer compatible with the current workspace. A confirmation dialog shows how many actions will be removed."
+msgstr "|remove_incompatible| **Supprimer les incompatibles** : supprime toutes les actions dont l'état de l'espace de travail n'est plus compatible avec l'espace de travail actuel. Une boîte de dialogue de confirmation indique combien d'actions seront supprimées."
+
+msgid "remove_incompatible"
+msgstr "remove_incompatible"
+
+msgid "|delete| **Delete**: remove the selected actions or sessions from the history."
+msgstr "|delete| **Supprimer** : retire de l'historique les actions ou sessions sélectionnées."
+
+msgid "delete"
+msgstr "delete"
+
+msgid "|step_prev| **Previous step**: select the preceding action in the current session (keyboard shortcut: :kbd:`Ctrl+Left`)."
+msgstr "|step_prev| **Étape précédente** : sélectionne l'action précédente dans la session courante (raccourci clavier : :kbd:`Ctrl+Gauche`)."
+
+msgid "step_prev"
+msgstr "step_prev"
+
+msgid "|step_next| **Next step**: select the following action in the current session (keyboard shortcut: :kbd:`Ctrl+Right`)."
+msgstr "|step_next| **Étape suivante** : sélectionne l'action suivante dans la session courante (raccourci clavier : :kbd:`Ctrl+Droite`)."
+
+msgid "step_next"
+msgstr "step_next"
+
+msgid "Double-clicking on an action row in the tree is equivalent to **Replay**."
+msgstr "Un double-clic sur la ligne d'une action dans l'arborescence équivaut à **Rejouer**."
+
+msgid "Tree view"
+msgstr "Arborescence"
+
+msgid "The tree view organizes recorded actions into expandable sessions:"
+msgstr "L'arborescence organise les actions enregistrées dans des sessions dépliables :"
+
+msgid "Each top-level row is a **session**, automatically created when recording is enabled and a new application context is started."
+msgstr "Chaque ligne de premier niveau est une **session**, créée automatiquement lorsque l'enregistrement est activé et qu'un nouveau contexte d'application est démarré."
+
+msgid "Each child row is an **action**, with its title, date/time and a description summarising the parameters (for computations) or the call (for UI actions)."
+msgstr "Chaque ligne enfant est une **action**, accompagnée de son titre, de sa date et de son heure, ainsi que d'une description résumant les paramètres (pour les calculs) ou l'appel (pour les actions de l'interface)."
+
+msgid "The selection of one or several rows drives which actions are targeted by the toolbar buttons."
+msgstr "La sélection d'une ou de plusieurs lignes détermine les actions ciblées par les boutons de la barre d'outils."
+
+msgid "Actions that are not compatible with the current workspace state (for example because a referenced object identifier no longer exists, or because its data shape changed) are shown with a disabled foreground and an explanatory tooltip. They cannot be replayed until the workspace matches the recorded state again."
+msgstr "Les actions qui ne sont pas compatibles avec l'état courant de l'espace de travail (par exemple parce qu'un identifiant d'objet référencé n'existe plus, ou parce que la forme de ses données a changé) sont affichées avec un texte désactivé et une infobulle explicative. Elles ne peuvent pas être rejouées tant que l'espace de travail ne correspond pas de nouveau à l'état enregistré."
+
+msgid "Workspace state display"
+msgstr "Affichage de l'état de l'espace de travail"
+
+msgid "Below the action tree, a split-view widget shows the **workspace state** captured at the time of the selected action:"
+msgstr "Sous l'arborescence des actions, un widget en vue divisée affiche l'**état de l'espace de travail** tel qu'il était au moment de l'action sélectionnée :"
+
+msgid "**Left table**: lists the signals that were selected, with their data shape."
+msgstr "**Tableau de gauche** : liste les signaux qui étaient sélectionnés, avec leur forme de données."
+
+msgid "**Right table**: lists the images that were selected, with their dimensions."
+msgstr "**Tableau de droite** : liste les images qui étaient sélectionnées, avec leurs dimensions."
+
+msgid "This information helps the user understand the context in which each action was originally executed and diagnose compatibility issues when replaying sessions on a different workspace."
+msgstr "Ces informations aident l'utilisateur à comprendre le contexte dans lequel chaque action a été exécutée à l'origine et à diagnostiquer les problèmes de compatibilité lors du rejeu de sessions sur un espace de travail différent."
+
+msgid "Session replay across workspaces"
+msgstr "Rejeu d'une session entre espaces de travail"
+
+msgid "A full session can be replayed on a workspace that no longer contains the objects originally referenced by the recorded actions -- typically after loading a saved session into a fresh workspace. In that case, the panel **remaps the recorded object identifiers** to the newly-created ones on the fly:"
+msgstr "Une session complète peut être rejouée sur un espace de travail qui ne contient plus les objets référencés à l'origine par les actions enregistrées -- typiquement après le chargement d'une session sauvegardée dans un espace de travail vierge. Dans ce cas, le panneau **réassocie à la volée les identifiants d'objets enregistrés** aux nouveaux identifiants créés :"
+
+msgid "UI actions creating new objects (e.g. *New signal*) enqueue the freshly created identifiers;"
+msgstr "les actions de l'interface qui créent de nouveaux objets (par exemple *Nouveau signal*) empilent les identifiants fraîchement créés ;"
+
+msgid "subsequent computations claim the identifiers they need from that queue, in the same order as the original recording;"
+msgstr "les calculs ultérieurs récupèrent dans cette file les identifiants dont ils ont besoin, dans l'ordre de l'enregistrement initial ;"
+
+msgid "UI actions removing objects keep the queue in sync with the live workspace contents, so chained creation/removal sequences replay correctly."
+msgstr "les actions de l'interface qui suppriment des objets maintiennent la file synchronisée avec le contenu réel de l'espace de travail, de sorte que les séquences enchaînées de création et de suppression se rejouent correctement."
+
+msgid "This makes it possible, for instance, to record a full processing chain on one dataset, save it, then re-apply the exact same chain on a different but structurally identical input."
+msgstr "Cela permet par exemple d'enregistrer une chaîne de traitement complète sur un jeu de données, de la sauvegarder, puis de la ré-appliquer telle quelle à une entrée différente mais structurellement identique."
+
+msgid "Persistence"
+msgstr "Persistance"
+
+msgid "The history can be persisted in two complementary ways:"
+msgstr "L'historique peut être persisté de deux manières complémentaires :"
+
+msgid "**Embedded in the workspace**: when the workspace is saved to HDF5 (``File > Save to HDF5 file``), the History Panel content is automatically saved alongside the signals and images. Reloading the workspace restores the recorded sessions."
+msgstr "**Intégré à l'espace de travail** : lorsque l'espace de travail est enregistré au format HDF5 (``Fichier > Enregistrer dans un fichier HDF5``), le contenu du Panneau historique est automatiquement sauvegardé aux côtés des signaux et des images. Le rechargement de l'espace de travail restaure les sessions enregistrées."
+
+msgid "**Standalone history file** (``.dlhist``): the file embeds both the recorded sessions **and** all signal/image objects referenced by those sessions. This makes the file fully self-contained:"
+msgstr "**Fichier d'historique autonome** (``.dlhist``) : le fichier embarque à la fois les sessions enregistrées **et** tous les objets signal/image référencés par ces sessions. Cela rend le fichier entièrement autonome :"
+
+msgid "Opening a ``.dlhist`` into an **empty workspace** loads sessions and objects directly, restoring the workspace to its recorded state."
+msgstr "Ouvrir un fichier ``.dlhist`` dans un **espace de travail vide** charge les sessions et les objets directement, restaurant l'espace de travail dans son état enregistré."
+
+msgid "Opening a ``.dlhist`` into a **non-empty workspace** creates new signal/image groups for the imported objects (with remapped identifiers to avoid collisions) and appends new history sessions that reference those fresh identifiers."
+msgstr "Ouvrir un fichier ``.dlhist`` dans un **espace de travail non vide** crée de nouveaux groupes signal/image pour les objets importés (avec des identifiants remappés pour éviter les collisions) et ajoute de nouvelles sessions d'historique référençant ces nouveaux identifiants."
+
+msgid "Replaying a session that depends on external files (e.g. opening a dataset from disk) will only succeed if those files are still available at the same locations as when the session was recorded."
+msgstr "Le rejeu d'une session qui dépend de fichiers externes (par exemple l'ouverture d'un jeu de données depuis le disque) ne réussira que si ces fichiers sont toujours disponibles aux mêmes emplacements qu'au moment de l'enregistrement de la session."
+
+msgid "Chain reconnection on deletion"
+msgstr "Reconnexion de la chaîne lors d'une suppression"
+
+msgid "When a result object is deleted from the **signal or image panel** (not from the History Panel tree), and that object was produced by a recorded processing step, the History Panel automatically reconnects the processing chain:"
+msgstr "Lorsqu'un objet résultat est supprimé depuis le **panneau signal ou image** (et non depuis l'arborescence du panneau historique), et que cet objet a été produit par une étape de traitement enregistrée, le Panneau historique reconnecte automatiquement la chaîne de traitement :"
+
+msgid "All downstream steps that consumed the deleted object are rewired to use the source of the deleted step as their new input."
+msgstr "Toutes les étapes aval qui consommaient l'objet supprimé sont recâblées pour utiliser la source de l'étape supprimée comme nouvelle entrée."
+
+msgid "For ``2_to_1`` operations (e.g. *difference*), the first source is used for reconnection."
+msgstr "Pour les opérations ``2_to_1`` (par exemple *différence*), la première source est utilisée pour la reconnexion."
+
+msgid "If no valid source can be determined (e.g. the source itself was already deleted), a warning is displayed listing the unreconnectable operations, but the deletion is allowed to proceed."
+msgstr "Si aucune source valide ne peut être déterminée (par exemple la source elle-même a déjà été supprimée), un avertissement est affiché listant les opérations non reconnectables, mais la suppression est néanmoins autorisée."
+
+msgid "This behaviour mirrors removing a link from a chain: the adjacent links reconnect to preserve the processing flow."
+msgstr "Ce comportement reproduit la suppression d'un maillon d'une chaîne : les maillons adjacents se reconnectent pour préserver le flux de traitement."
+
+msgid "Reconnection is only triggered by deletions initiated from the signal/image panels. Deleting an action directly from the History Panel tree removes it and all subsequent actions in that session."
+msgstr "La reconnexion n'est déclenchée que par les suppressions initiées depuis les panneaux signal/image. Supprimer une action directement depuis l'arborescence du Panneau historique la retire ainsi que toutes les actions suivantes de cette session."
+
+msgid "Auto-recompute"
+msgstr "Recalcul automatique"
+
+msgid "When a result object is selected in the signal/image panel and it has processing parameters (i.e. was produced by a 1-to-1 computation), a **Processing** tab appears in the Properties panel. Checking **Auto-recompute on edit** in that tab will re-run the computation automatically 300 ms after any parameter modification."
+msgstr "Lorsqu'un objet résultat est sélectionné dans le panneau signal/image et qu'il possède des paramètres de traitement (c'est-à-dire qu'il a été produit par un calcul 1-à-1), un onglet **Traitement** apparaît dans le panneau Propriétés. Cocher **Recalcul automatique lors de l'édition** dans cet onglet relancera automatiquement le calcul 300 ms après toute modification d'un paramètre."
diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
index 4e57afc5..96083084 100644
--- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
+++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
@@ -23,17 +23,40 @@ msgstr "✨ Nouvelles fonctionnalités"
msgid "**Third-party plugin discovery via environment variable:**"
msgstr "**Découverte des plugins tiers via une variable d'environnement :**"
-msgid "Added support for the `DATALAB_PLUGINS` environment variable, allowing one or more directories to be specified as additional plugin search paths"
-msgstr "Ajout de la prise en charge de la variable d'environnement `DATALAB_PLUGINS`, permettant de spécifier un ou plusieurs répertoires comme chemins de recherche supplémentaires pour les plugins"
+msgid ""
+"Added support for the `DATALAB_PLUGINS` environment variable, allowing "
+"one or more directories to be specified as additional plugin search paths"
+msgstr ""
+"Ajout de la prise en charge de la variable d'environnement "
+"`DATALAB_PLUGINS`, permettant de spécifier un ou plusieurs répertoires "
+"comme chemins de recherche supplémentaires pour les plugins"
-msgid "Multiple directories can be listed using the OS path separator (`;` on Windows, `:` on Linux/macOS), following the same convention as `PYTHONPATH`"
-msgstr "Plusieurs répertoires peuvent être listés en utilisant le séparateur de chemin du système d'exploitation (`;` sous Windows, `:` sous Linux/macOS), selon la même convention que `PYTHONPATH`"
+msgid ""
+"Multiple directories can be listed using the OS path separator (`;` on "
+"Windows, `:` on Linux/macOS), following the same convention as "
+"`PYTHONPATH`"
+msgstr ""
+"Plusieurs répertoires peuvent être listés en utilisant le séparateur de "
+"chemin du système d'exploitation (`;` sous Windows, `:` sous "
+"Linux/macOS), selon la même convention que `PYTHONPATH`"
-msgid "Listed directories are appended to the existing plugin search paths at startup and are picked up automatically by the plugin discovery mechanism"
-msgstr "Les répertoires listés sont ajoutés aux chemins de recherche existants au démarrage et sont automatiquement pris en compte par le mécanisme de découverte des plugins"
+msgid ""
+"Listed directories are appended to the existing plugin search paths at "
+"startup and are picked up automatically by the plugin discovery mechanism"
+msgstr ""
+"Les répertoires listés sont ajoutés aux chemins de recherche existants au"
+" démarrage et sont automatiquement pris en compte par le mécanisme de "
+"découverte des plugins"
-msgid "Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting"
-msgstr "Les répertoires inexistants sont ignorés silencieusement (un avertissement est consigné dans le fichier journal), ainsi une variable d'environnement obsolète sur une autre machine n'empêchera pas le démarrage de DataLab"
+msgid ""
+"Non-existent directories are silently skipped (a warning is recorded in "
+"the log file), so a stale environment variable on another machine will "
+"not prevent DataLab from starting"
+msgstr ""
+"Les répertoires inexistants sont ignorés silencieusement (un "
+"avertissement est consigné dans le fichier journal), ainsi une variable "
+"d'environnement obsolète sur une autre machine n'empêchera pas le "
+"démarrage de DataLab"
msgid "**Replace special values processing (signal and image):**"
msgstr "**Traitement de remplacement des valeurs spéciales (signal et image) :**"
@@ -58,3 +81,25 @@ msgstr "Lorsqu'une stratégie de voisinage est sélectionnée, un **aperçu en d
msgid "Integer images are handled explicitly: because `NaN` and infinite values cannot exist in integer data, the dialog explains that the operation is not applicable and prevents accidental processing, while preserving the original image data type without unnecessary float conversion"
msgstr "Les images entières sont traitées explicitement : comme les valeurs `NaN` et infinies ne peuvent pas exister dans les données entières, la boîte de dialogue explique que l'opération n'est pas applicable et empêche un traitement accidentel, tout en préservant le type de données d'image d'origine sans conversion inutile en flottant"
+
+msgid "**History Panel sessions:**"
+msgstr "**Sessions du panneau d'historique :**"
+
+msgid ""
+"Added serialized and replayable history sessions with workspace-state "
+"validation"
+msgstr ""
+"Ajout de sessions d'historique sérialisées et rejouables, avec validation "
+"de l'état de l'espace de travail"
+
+msgid ""
+"Added `.dlhist` import/export support and separated reset sessions from "
+"regular history sessions"
+msgstr ""
+"Ajout de la prise en charge de l'import/export `.dlhist` et séparation des "
+"sessions de réinitialisation des sessions d'historique ordinaires"
+
+msgid "Improved replay compatibility reporting for clearer user feedback"
+msgstr ""
+"Amélioration du rapport de compatibilité de relecture pour fournir un retour "
+"utilisateur plus clair"
diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md
index a4350ccc..f0a613c0 100644
--- a/doc/release_notes/release_1.03.md
+++ b/doc/release_notes/release_1.03.md
@@ -11,6 +11,18 @@
* Listed directories are appended to the existing plugin search paths at startup and are picked up automatically by the plugin discovery mechanism
* Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting
+**History Panel sessions:**
+
+* Added serialized and replayable history sessions with workspace-state validation
+* Added `.dlhist` import/export support and separated reset sessions from regular history sessions
+* Improved replay compatibility reporting for clearer user feedback
+
+**History Panel sessions:**
+
+* Added serialized and replayable history sessions with workspace-state validation
+* Added `.dlhist` import/export support and separated reset sessions from regular history sessions
+* Improved replay compatibility reporting for clearer user feedback
+
**Replace special values processing (signal and image):**
DataLab now provides a dedicated **"Replace special values"** processing
diff --git a/doc/update_screenshots.py b/doc/update_screenshots.py
index 2ae380d1..8858bb1c 100644
--- a/doc/update_screenshots.py
+++ b/doc/update_screenshots.py
@@ -6,6 +6,7 @@
from datalab import config
from datalab.tests.features.applauncher import launcher1_app_test
+from datalab.tests.features.common import history_panel_app_test
from datalab.tests.features.utilities import settings_unit_test
from datalab.tests.scenarios import beautiful_app
@@ -17,4 +18,5 @@
beautiful_app.run_beautiful_scenario(screenshots=True)
beautiful_app.run_blob_detection_on_flower_image(screenshots=True)
settings_unit_test.capture_settings_screenshots()
+ history_panel_app_test.test_history_panel(screenshots=True)
print("done.")