Another implementation of undo/redo

This commit is contained in:
2025-07-31 22:54:09 +02:00
parent 72f5f30da6
commit 37c91d0d5d
13 changed files with 228 additions and 94 deletions

View File

@@ -1,30 +1,24 @@
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from fasthtml.components import *
from components.BaseComponent import BaseComponentSingleton
from components.undo_redo.assets.icons import icon_redo, icon_undo
from components.undo_redo.commands import UndoRedoCommandManager
from components.undo_redo.constants import UNDO_REDO_INSTANCE_ID
from components.undo_redo.constants import UNDO_REDO_INSTANCE_ID, UndoRedoAttrs
from components_helpers import mk_icon, mk_tooltip
logger = logging.getLogger("UndoRedoApp")
class CommandHistory(ABC):
def __init__(self, name, desc, owner):
self.name = name
self.desc = desc
self.owner = owner
@abstractmethod
def undo(self):
pass
@abstractmethod
def redo(self):
pass
@dataclass
class CommandHistory:
attrs: UndoRedoAttrs
digest: str | None # digest to remember
entry: str # digest to remember
key: str # key
path: str # path within the key if only on subitem needs to be updated
class UndoRedo(BaseComponentSingleton):
@@ -35,35 +29,73 @@ class UndoRedo(BaseComponentSingleton):
self.index = -1
self.history = []
self._commands = UndoRedoCommandManager(self)
self._db_engine = settings_manager.get_db_engine()
def push(self, command: CommandHistory):
self.history = self.history[:self.index + 1]
def snapshot(self, undo_redo_attrs: UndoRedoAttrs, entry, key, path=None):
digest = self._settings_manager.get_digest(self._session, entry) # get the current digest (the last one)
# init the history if this is the first call
if len(self.history) == 0:
digest_history = self._settings_manager.history(self._session, entry, digest, 2)
command = CommandHistory(undo_redo_attrs,
digest_history[1] if len(digest_history) > 1 else None,
entry,
key,
path)
self.history.append(command)
self.index = 0
command = CommandHistory(undo_redo_attrs, digest, entry, key, path)
self.history = self.history[:self.index + 1] #
self.history.append(command)
self.index += 1
self.index = len(self.history) - 1
def undo(self):
logger.debug(f"Undo command")
if self.index < 0 :
if self.index < 1:
logger.debug(f" No command to undo.")
return self
command = self.history[self.index]
logger.debug(f" Undoing command {command.name} ({command.desc})")
res = command.undo()
current = self.history[self.index]
current_state = self._settings_manager.load(self._session, None, digest=current.digest)
previous = self.history[self.index - 1]
previous_state = self._settings_manager.load(self._session, None, digest=previous.digest)
# reapply the state
current_state[current.key] = previous_state[current.key]
self._settings_manager.save(self._session, current.entry, current_state)
self.index -= 1
return self, res
if current.attrs.on_undo is not None:
return self, current.attrs.on_undo()
else:
return self
def redo(self):
logger.debug("Redo command")
if self.index == len(self.history) - 1:
logger.debug(f" No command to redo.")
logger.debug(f"Redo command")
if self.index >= len(self.history) - 1:
logger.debug(f" No command to undo.")
return self
current = self.history[self.index]
current_state = self._settings_manager.load(self._session, None, digest=current.digest)
next_ = self.history[self.index + 1]
next_state = self._settings_manager.load(self._session, None, digest=next_.digest)
# reapply the state
current_state[current.key] = next_state[current.key]
self._settings_manager.save(self._session, current.entry, current_state)
self.index += 1
command = self.history[self.index]
logger.debug(f" Redoing command {command.name} ({command.desc})")
res = command.redo()
return self, res
if current.attrs.on_undo is not None:
return self, current.attrs.on_redo()
else:
return self
def refresh(self):
return self.__ft__(oob=True)
@@ -83,7 +115,7 @@ class UndoRedo(BaseComponentSingleton):
return mk_tooltip(mk_icon(icon_undo,
size=24,
**self._commands.undo()),
f"Undo '{command.name}'.")
f"Undo '{command.attrs.name}'.")
else:
return mk_tooltip(mk_icon(icon_undo,
size=24,
@@ -97,7 +129,7 @@ class UndoRedo(BaseComponentSingleton):
return mk_tooltip(mk_icon(icon_redo,
size=24,
**self._commands.redo()),
f"Redo '{command.name}'.")
f"Redo '{command.attrs.name}'.")
else:
return mk_tooltip(mk_icon(icon_redo,
size=24,
@@ -106,7 +138,7 @@ class UndoRedo(BaseComponentSingleton):
"Nothing to redo.")
def _can_undo(self):
return self.index >= 0
return self.index >= 1
def _can_redo(self):
return self.index < len(self.history) - 1

View File

@@ -1,3 +1,6 @@
from dataclasses import dataclass
from typing import Callable
UNDO_REDO_INSTANCE_ID = "__UndoRedo__"
ROUTE_ROOT = "/undo"
@@ -5,4 +8,16 @@ ROUTE_ROOT = "/undo"
class Routes:
Undo = "/undo"
Redo = "/redo"
Redo = "/redo"
@dataclass
class UndoRedoAttrs:
name: str
desc: str = None
on_undo: Callable = None
on_redo: Callable = None
def __post_init__(self):
if self.on_redo is None:
self.on_redo = self.on_undo

View File

@@ -129,14 +129,20 @@ def post(session, _id: str, component_id: str, event_name: str, details: dict):
@rt(Routes.PlayWorkflow)
def post(session, _id: str, tab_boundaries: str):
logger.debug(
f"Entering {Routes.PlayWorkflow} with args {debug_session(session)}, {_id=}")
logger.debug(f"Entering {Routes.PlayWorkflow} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.play_workflow(json.loads(tab_boundaries))
@rt(Routes.StopWorkflow)
def post(session, _id: str):
logger.debug(
f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}")
logger.debug(f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.stop_workflow()
return instance.stop_workflow()
@rt(Routes.Refresh)
def post(session, _id: str):
logger.debug(f"Entering {Routes.Refresh} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.refresh()

View File

@@ -23,3 +23,6 @@ icon_pause_circle = NotStr(
# fluent RecordStop20Regular
icon_stop_circle = NotStr(
"""<svg name="stop" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20"><g fill="none"><path d="M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14zm-8 7a8 8 0 1 1 16 0a8 8 0 0 1-16 0zm5-2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V8z" fill="currentColor"></path></g></svg>""")
# fluent ArrowClockwise20Regular
icon_refresh = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20"><g fill="none"><path d="M3.066 9.05a7 7 0 0 1 12.557-3.22l.126.17H12.5a.5.5 0 1 0 0 1h4a.5.5 0 0 0 .5-.5V2.502a.5.5 0 0 0-1 0v2.207a8 8 0 1 0 1.986 4.775a.5.5 0 0 0-.998.064A7 7 0 1 1 3.066 9.05z" fill="currentColor"></path></g></svg>""")

View File

@@ -1,23 +1,6 @@
from components.BaseCommandManager import BaseCommandManager
from components.undo_redo.components.UndoRedo import CommandHistory
from components.workflows.constants import Routes, ROUTE_ROOT
class AddConnectorCommand(CommandHistory):
def __init__(self, owner, connector):
super().__init__("Add connector", "Add connector", owner)
self.connector = connector
def undo(self):
del self.owner.get_state().components[self.connector.id]
self.owner.get_db().save_state(self.owner.get_key(), self.owner.get_state()) # update db
return self.owner.refresh_designer(True)
def redo(self, oob=True):
self.owner.get_state().components[self.connector.id] = self.connector
self.owner.get_db().save_state(self.owner.get_key(), self.owner.get_state()) # update db
return self.owner.refresh_designer(oob)
class WorkflowsCommandManager(BaseCommandManager):
def __init__(self, owner):
@@ -108,6 +91,13 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
def refresh(self):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.Refresh}",
"hx-swap": "none",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
class WorkflowPlayerCommandManager(BaseCommandManager):

View File

@@ -6,8 +6,9 @@ from fasthtml.xtend import Script
from assets.icons import icon_error
from components.BaseComponent import BaseComponent
from components.workflows.assets.icons import icon_play, icon_pause, icon_stop
from components.workflows.commands import WorkflowDesignerCommandManager, AddConnectorCommand
from components.undo_redo.constants import UndoRedoAttrs
from components.workflows.assets.icons import icon_play, icon_pause, icon_stop, icon_refresh
from components.workflows.commands import WorkflowDesignerCommandManager
from components.workflows.components.WorkflowPlayer import WorkflowPlayer
from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \
@@ -63,6 +64,7 @@ class WorkflowDesigner(BaseComponent):
self._key = key
self._designer_settings = designer_settings
self._db = WorkflowsDesignerDbManager(session, settings_manager)
self._undo_redo = ComponentsInstancesHelper.get_undo_redo(session)
self._state = self._db.load_state(key)
self._boundaries = boundaries
self.commands = WorkflowDesignerCommandManager(self)
@@ -96,6 +98,13 @@ class WorkflowDesigner(BaseComponent):
def refresh_properties(self, oob=False):
return self._mk_properties(oob)
def refresh(self):
return self.__ft__(oob=True)
def refresh_state(self):
self._state = self._db.load_state(self._key)
return self.__ft__(oob=True)
def add_component(self, component_type, x, y):
self._state.component_counter += 1
@@ -111,24 +120,19 @@ class WorkflowDesigner(BaseComponent):
description=info["description"],
properties={"processor_name": PROCESSOR_TYPES[component_type][0]}
)
command = AddConnectorCommand(self, component)
undo_redo = ComponentsInstancesHelper.get_undo_redo(self._session)
#undo_redo.push(command)
self._state.components[component_id] = component
self._db.save_state(self._key, self._state) # update db
undo_redo.snapshot("add_component")
return command.redo(), undo_redo.refresh()
# self._state.components[component_id] = component
# self._db.save_state(self._key, self._state) # update db
# return self.refresh_designer()
undo_redo_attrs = UndoRedoAttrs(f"Add Component '{component_type}'", on_undo=lambda: self.refresh_state())
self._db.save_state(self._key, self._state, undo_redo_attrs) # update db
return self.refresh_designer(), self._undo_redo.refresh()
def move_component(self, component_id, x, y):
if component_id in self._state.components:
self._state.selected_component_id = component_id
self._state.components[component_id].x = int(x)
self._state.components[component_id].y = int(y)
self._db.save_state(self._key, self._state) # update db
self._db.save_state(self._key, self._state, ) # update db
return self.refresh_designer(), self.refresh_properties(True)
@@ -247,12 +251,13 @@ class WorkflowDesigner(BaseComponent):
def get_workflow_connections(self):
return self._state.connections
def __ft__(self):
def __ft__(self, oob=False):
return Div(
H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"),
P("Drag components from the toolbox to the canvas to create your workflow.", cls="text-sm mb-6"),
Div(
self._mk_media(),
#self._mk_refresh_button(),
self._mk_error_message(),
cls="flex mb-2",
id=f"t_{self._id}"
@@ -263,6 +268,7 @@ class WorkflowDesigner(BaseComponent):
Script(f"bindWorkflowDesigner('{self._id}');"),
**apply_boundaries(self._boundaries),
id=f"{self._id}",
hx_swap_oob='true' if oob else None,
)
def _mk_connection_svg(self, conn: Connection):
@@ -384,6 +390,9 @@ class WorkflowDesigner(BaseComponent):
cls=f"media-controls flex m-2"
)
def _mk_refresh_button(self):
return mk_icon(icon_refresh, **self.commands.refresh())
def _mk_error_message(self):
if not self._error_message:
return Div()

View File

@@ -32,4 +32,5 @@ class Routes:
PlayWorkflow = "/play-workflow"
PauseWorkflow = "/pause-workflow"
StopWorkflow = "/stop-workflow"
Refresh = "/refresh"

View File

@@ -2,10 +2,12 @@ import enum
import logging
from dataclasses import dataclass, field
from components.undo_redo.constants import UndoRedoAttrs
from components.workflows.constants import WORKFLOWS_DB_ENTRY, WORKFLOW_DESIGNER_DB_ENTRY, \
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY, WORKFLOW_DESIGNER_DB_STATE_ENTRY
from core.settings_management import SettingsManager
from core.utils import make_safe_id
from utils.ComponentsInstancesHelper import ComponentsInstancesHelper
logger = logging.getLogger("WorkflowsSettings")
@@ -158,6 +160,7 @@ class WorkflowsDesignerDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager):
self._session = session
self._settings_manager = settings_manager
self._undo_redo = ComponentsInstancesHelper.get_undo_redo(session)
@staticmethod
def _get_db_entry(key):
@@ -169,11 +172,17 @@ class WorkflowsDesignerDbManager:
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY,
settings)
def save_state(self, key: str, state: WorkflowsDesignerState):
def save_state(self, key: str, state: WorkflowsDesignerState, undo_redo_attrs: UndoRedoAttrs = None):
db_entry = self._get_db_entry(key)
self._settings_manager.put(self._session,
self._get_db_entry(key),
db_entry,
WORKFLOW_DESIGNER_DB_STATE_ENTRY,
state)
if undo_redo_attrs is not None:
self._undo_redo.snapshot(undo_redo_attrs,
db_entry,
WORKFLOW_DESIGNER_DB_STATE_ENTRY)
def save_all(self, key: str, settings: WorkflowsDesignerSettings = None, state: WorkflowsDesignerState = None):
items = {}