penpot/frontend/src/app/main/data/changes.cljs
alonso.torres 9e8be56eb3 🐛 Revert whole user actions in a rollback
A rollback reverts whole undo groups, across files and saves, so that
no user action is left half done. Some actions still split:

- An action that commits several times inside one undo transaction
  (e.g. applying a token to several shapes) gave each commit its own
  group, so a rejection could revert part of it: shapes ended up linked
  to a token whose value was never applied. An undo transaction now
  opens with a commit group that every commit made inside it takes.
- The commit that undoes saved commits of a rejected action joined an
  open transaction's group, so a later rejection there reverted the
  undo too. It gets a group of its own.
- Emptying the open transaction during a rollback dropped its group, so
  its later commits were no longer one unit. It keeps its group.
- Waiting for a save ended as soon as the queue emptied, while later
  commits could still wait in the buffer. Waiters now also wait for an
  empty buffer.

A test covers a token action of three commits in one save whose third
commit is rejected.
2026-11-03 11:17:19 +01:00

314 lines
12 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.data.changes
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes :as cpc]
[app.common.logging :as log]
[app.common.time :as ct]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.persistence.commits :as pcm]
[app.main.data.workspace :as-alias dw]
[app.main.features :as features]
[app.main.worker :as mw]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.shape :as wasm.shape]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
;; Change this to :info :debug or :trace to debug this module
(log/set-level! :warn)
(def page-change?
#{:add-page :mod-page :del-page :mov-page})
(def update-layout-attr?
#{:hidden})
(def commit?
(ptk/type? ::commit))
(defn- fix-page-id
"For events that modifies the page, page-id does not comes
as a property so we assign it from the `id` property."
[{:keys [id type page] :as change}]
(cond-> change
(and (page-change? type)
(nil? (:page-id change)))
(assoc :page-id (or id (:id page)))))
(defn- update-indexes
"Given a commit, send the changes to the worker for updating the
indexes."
[commit attr]
(ptk/reify ::update-indexes
ptk/WatchEvent
(watch [_ _ _]
(let [changes (->> (get commit attr)
(map fix-page-id)
(filter :page-id)
(group-by :page-id))]
(->> (rx/from changes)
(rx/merge-map (fn [[page-id changes]]
(log/debug :hint "update-indexes" :page-id page-id :changes (count changes))
(mw/ask! {:cmd :index/update
:page-id page-id
:changes changes})))
(rx/catch (fn [cause]
(log/warn :hint "unable to update index"
:cause cause)
(rx/empty)))
(rx/ignore))))))
(def ^:private xf:map-page-id
;; Component-only changes have no :page-id; keep drops those nils so
;; update-object-indices does not create a nil key in :pages-index.
(keep :page-id))
(def ^:private wasm-structural-change-types
#{:add-obj :mov-objects})
(defn- redo-changes-need-wasm-object-sync?
[redo-changes]
(some #(contains? wasm-structural-change-types (:type %)) redo-changes))
(defn- sync-wasm-structural-changes
[{:keys [redo-changes]}]
(ptk/reify ::sync-wasm-structural-changes
ptk/EffectEvent
(effect [_ state _]
(when (wasm.api/initialized?)
(let [objects (dsh/lookup-page-objects state)
shapes
(into []
(keep (fn [{:keys [type id parent-id]}]
(when (contains? wasm-structural-change-types type)
(get objects (case type
:add-obj id
:mov-objects parent-id)))))
redo-changes)]
(wasm.api/process-objects shapes)
(wasm.api/request-render "sync-wasm-structural-changes"))))))
(defn- apply-changes-localy
[{:keys [file-id redo-changes ignore-wasm?] :as commit} pending]
(ptk/reify ::apply-changes-localy
ptk/UpdateEvent
(update [_ state]
(let [undo-changes
(if pending
(->> pending
(map :undo-changes)
(reverse)
(mapcat identity)
(vec))
nil)
redo-changes
(if pending
(into redo-changes
(mapcat :redo-changes)
pending)
redo-changes)
apply-changes
(fn [fdata]
(let [fdata (cpc/process-changes fdata undo-changes false)
fdata (cpc/process-changes fdata redo-changes false)
pids (into #{} xf:map-page-id redo-changes)]
(reduce #(ctst/update-object-indices %1 %2) fdata pids)))]
(if (and (not ignore-wasm?) (features/active-feature? state "render-wasm/v1"))
;; Update the wasm model
(let [shape-changes (volatile! {})
state
(binding [cts/*shape-changes* shape-changes]
(update-in state [:files file-id :data] apply-changes))]
(let [objects (dm/get-in state [:files file-id :data :pages-index (:current-page-id state) :objects])]
(wasm.shape/process-shape-changes! objects @shape-changes))
state)
;; wasm renderer deactivated
(update-in state [:files file-id :data] apply-changes))))
ptk/WatchEvent
(watch [_ state _]
;; `:add-obj` / `:mov-objects` are not tracked via `*shape-changes*`.
;; Emit only for structural commits, after file data is in state.
(when (and (not ignore-wasm?)
(features/active-feature? state "render-wasm/v1")
(redo-changes-need-wasm-object-sync? redo-changes))
(rx/of (sync-wasm-structural-changes {:redo-changes redo-changes}))))))
(defn commit
"Create a commit event instance"
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm?
selected-before translation? skip-component-sync?]}]
(assert (cpc/check-changes redo-changes)
"expect valid vector of changes for redo-changes")
(assert (cpc/check-changes undo-changes)
"expect valid vector of changes for undo-changes")
(let [commit-id (or commit-id (uuid/next))
source (d/nilv source :local)
local? (= source :local)
commit {:id commit-id
:created-at (ct/now)
:source source
:origin (ptk/type origin)
:features features
:file-id file-id
:file-revn file-revn
:file-vern file-vern
:changes redo-changes
:redo-changes redo-changes
:undo-changes undo-changes
:save-undo? save-undo?
:undo-group undo-group
:tags tags
:stack-undo? stack-undo?
:ignore-wasm? ignore-wasm?
:selected-before selected-before
:translation? translation?
:skip-component-sync? skip-component-sync?}]
(ptk/reify ::commit
cljs.core/IDeref
(-deref [_] commit)
ptk/WatchEvent
(watch [_ state _]
(let [pending (when-not local?
(not-empty (pcm/pending-commits (:persistence state) file-id)))]
(rx/concat
(rx/of (apply-changes-localy commit pending))
(if pending
(rx/concat
(->> (rx/from (reverse pending))
(rx/map (fn [commit] (update-indexes commit :undo-changes))))
(rx/of (update-indexes commit :redo-changes))
(->> (rx/from pending)
(rx/map (fn [commit] (update-indexes commit :redo-changes)))))
(rx/of (update-indexes commit :redo-changes)))))))))
(defn rebase-commits
"Undoes `undo`, applies `remote` and redoes `redo` on the local data of
`file-id`, commits oldest first. Nothing is saved or added to the undo
history; if the changes fail to apply, the file is reloaded."
[file-id {:keys [undo remote redo]}]
(let [commit {:file-id file-id
:redo-changes (-> []
(into (mapcat :undo-changes) (reverse undo))
(into remote)
(into (mapcat :redo-changes) redo))}
event (apply-changes-localy commit nil)
failed? (volatile! false)]
(ptk/reify ::rebase-commits
ptk/UpdateEvent
(update [_ state]
(if (dsh/lookup-file-data state file-id)
(try
(ptk/update event state)
(catch :default cause
(log/error :hint "unable to rebase commits" :file-id (str file-id) :cause cause)
(vreset! failed? true)
state))
state))
ptk/WatchEvent
(watch [_ state stream]
(cond
@failed?
(rx/of (ptk/event ::dw/reload-current-file))
(dsh/lookup-file-data state file-id)
(rx/merge
(or (ptk/watch event state stream) (rx/empty))
(rx/of (update-indexes commit :redo-changes)))
:else
(rx/empty))))))
(defn- resolve-file-revn
[state file-id]
(:revn (dsh/lookup-file state file-id)))
(defn- resolve-file-vern
[state file-id]
(:vern (dsh/lookup-file state file-id)))
(defn commit-changes
"Schedules a list of changes to execute now, and add the corresponding undo changes to
the undo stack.
Options:
- save-undo?: if set to false, do not add undo changes.
- undo-group: if some consecutive changes (or even transactions) share the same
undo-group, they will be undone or redone in a single step. Without
it, a commit made while an undo transaction is open joins the
transaction's group, and any other gets a new one.
"
[{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id
translation? skip-component-sync?]
:or {save-undo? true
stack-undo? false
tags #{}}
:as params}]
(ptk/reify ::commit-changes
ev/PerformanceEvent
ptk/WatchEvent
(watch [_ state _]
(let [file-id (or file-id (:current-file-id state))
uchg (vec undo-changes)
rchg (vec redo-changes)
features (get state :features)
permissions (get state :permissions)]
;; Historical previews must not create edits to the live file. Check
;; this when creating commits so previously queued edits can still save.
;; Refusals answer with an empty stream (never nil) so callers can
;; uniformly subscribe and observe termination.
(if (and (:can-edit permissions)
(not (dm/get-in state [:workspace-global :preview-id])))
(do
(log/trace :hint "commit-changes" :redo-changes redo-changes)
(let [selected (dm/get-in state [:workspace-local :selected])
tx (dm/get-in state [:workspace-undo :transaction])
;; Inside an undo transaction, the group of its history entry
;; (taken from its first commit), or the one it was opened with
undo-group (or undo-group (:undo-group tx) (:commit-group tx) (uuid/next))]
(rx/of (-> params
(assoc :undo-group undo-group)
(assoc :features features)
(assoc :tags tags)
(assoc :stack-undo? stack-undo?)
(assoc :save-undo? save-undo?)
(assoc :file-id file-id)
(assoc :file-revn (resolve-file-revn state file-id))
(assoc :file-vern (resolve-file-vern state file-id))
(assoc :undo-changes uchg)
(assoc :redo-changes rchg)
(assoc :selected-before selected)
(assoc :translation? translation?)
(assoc :skip-component-sync? skip-component-sync?)
(commit)))))
(rx/empty))))))