alonso.torres f4b20ed08f 🐛 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-05 23:59:32 +01:00

440 lines
18 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.workspace.undo
"Undo management for the workspace.
There are **undo entries**, **undo transactions** and **undo groups**.
The undo stack has a maximum size of `MAX-UNDO-SIZE` (50) entries.
Undo entries:
- Consist of `:undo-changes`, `:redo-changes`, `:undo-group`, `:tags`
- Can be:
- `added`: pushes a new entry on top of the undo stack
- `stacked`: appends undo/redo changes to the last undo item under
`:workspace-undo :items`
- `accumulated`: extends the current transaction with new changes
Undo transactions:
- Are a way to incrementally merge incoming changes into a single
undo entry.
- Are identified by a unique id, typically a `(js/Symbol)`
- There is at most one current undo transaction.
- Have a timeout, defaulting to 20s
- Are _accumulated_ into the current transaction via
`accumulate-undo-entry`
Undo groups:
- Can contain multiple undo entries
- Undo entries in a groups must be consecutive
Undo tags:
- Known values: `:alt-duplication` (copy-and-move with mouse+alt)
"
(: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.schema :as sm]
[app.common.time :as ct]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.common :as dcm]
[app.main.data.helpers :as dsh]
[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 ^:private
discard-transaction-time-millis (* 20 1000))
(def ^:private
schema:undo-entry
[:map {:title "undo-entry"}
[:undo-changes [:vector cpc/schema:change]]
[:redo-changes [:vector cpc/schema:change]]
[:undo-group ::sm/uuid]
[:tags [:set :keyword]]
[:selected-before {:optional true} [:maybe [:set ::sm/uuid]]]
[:selected-after {:optional true} [:maybe [:set ::sm/uuid]]]
;; When the entry was pushed onto the stack; used by the actions
;; history panel to show a relative timestamp next to each entry.
;; Issue #7660.
[:timestamp {:optional true} [:maybe some?]]
;; Display name of the profile that created the entry. The undo
;; stack is client-side per profile, so this is always the current
;; user; still stored explicitly so the UI does not need to reach
;; into profile state while rendering. Issue #7660.
[:by {:optional true} [:maybe :string]]])
(def check-undo-entry
(sm/check-fn schema:undo-entry))
(def MAX-UNDO-SIZE 50)
(defn- conj-undo-entry
[undo data]
(let [undo (conj undo data)
cnt (count undo)]
(if (> cnt MAX-UNDO-SIZE)
(subvec undo (- cnt MAX-UNDO-SIZE))
undo)))
(defn materialize-undo
"Updates the state to point to a specific index in the undo stack.
Used to materialize the undo stack when the user selects an entry
in the undo history."
[_changes index]
(ptk/reify ::materialize-undo
ptk/UpdateEvent
(update [_ state]
(update state :workspace-undo assoc :index index))))
(defn- profile-display-name
"Best-effort display name for the current profile. Prefers the full
name, falls back to the email, and finally to nil so the UI can
simply skip the 'by …' suffix when we have nothing useful to show.
Issue #7660."
[state]
(let [profile (get state :profile)]
(or (:fullname profile)
(:email profile))))
(defn- stamp-entry
"Attach creation metadata to an undo entry. We only stamp the timestamp
and author when they are missing so already-enriched entries (e.g.
coming from an accumulated transaction that was opened earlier) keep
their original creation time and attribution. Issue #7660."
[state entry]
(cond-> entry
(nil? (:timestamp entry)) (assoc :timestamp (ct/now))
(nil? (:by entry)) (assoc :by (profile-display-name state))))
(defn- add-undo-entry
[state entry]
(if (and entry
(not-empty (:undo-changes entry))
(not-empty (:redo-changes entry)))
(let [index (get-in state [:workspace-undo :index] -1)
items (get-in state [:workspace-undo :items] [])
items (->> items (take (inc index)) (into []))
items (conj-undo-entry items (stamp-entry state entry))]
(-> state
(update :workspace-undo assoc :items items
:index (min (inc index)
(dec MAX-UNDO-SIZE)))))
state))
(defn- stack-undo-entry
"Extends the current undo entry in the workspace with new changes if it
exists, or creates a new entry if it doesn't. When stacking onto an
existing entry, the entry's original timestamp is preserved so the
history panel keeps showing when the action originated. Issue #7660."
[state {:keys [undo-changes redo-changes selected-after] :as entry}]
(let [index (get-in state [:workspace-undo :index] -1)]
(if (>= index 0)
(update-in state [:workspace-undo :items index]
(fn [item]
(-> item
(update :undo-changes #(into undo-changes %))
(update :redo-changes #(into % redo-changes))
(assoc :selected-after selected-after))))
(add-undo-entry state entry))))
(defn- accumulate-undo-entry
"Extends the current undo transaction with new changes."
[state {:keys [undo-changes redo-changes undo-group tags selected-before selected-after]}]
(-> state
(update-in [:workspace-undo :transaction :undo-changes] #(into undo-changes %))
(update-in [:workspace-undo :transaction :redo-changes] #(into % redo-changes))
(cond-> (nil? (get-in state [:workspace-undo :transaction :undo-group]))
(assoc-in [:workspace-undo :transaction :undo-group] undo-group))
(cond-> (nil? (get-in state [:workspace-undo :transaction :selected-before]))
(assoc-in [:workspace-undo :transaction :selected-before] selected-before))
(assoc-in [:workspace-undo :transaction :selected-after] selected-after)
(assoc-in [:workspace-undo :transaction :tags] tags)))
(defn append-undo
"UpdateEvent to add an entry to the undo stack, or extend the current undo transaction
or last undo entry.
- If `stack?` is true, it will stack the entry on top of the current undo entry.
- If `stack?` is false, it will add a new entry to the undo stack.
- If there is an open transaction, it will accumulate the changes in that transaction."
[entry stack?]
(assert (check-undo-entry entry))
(assert (boolean? stack?))
(ptk/reify ::append-undo
ptk/UpdateEvent
(update [_ state]
(let [selected-after (dm/get-in state [:workspace-local :selected])
entry (assoc entry :selected-after selected-after)]
(cond
(and (get-in state [:workspace-undo :transaction])
(or (not stack?)
(d/not-empty? (get-in state [:workspace-undo :transaction :undo-changes]))
(d/not-empty? (get-in state [:workspace-undo :transaction :redo-changes]))))
(accumulate-undo-entry state entry)
stack?
(stack-undo-entry state entry)
:else
(add-undo-entry state entry))))))
(def empty-tx
{:undo-changes [] :redo-changes []})
(declare check-open-transactions)
(defn start-undo-transaction
"Start a transaction, so that changes in it are added together into a single undo entry."
[id & {:keys [timeout] :or {timeout discard-transaction-time-millis}}]
(ptk/reify ::start-undo-transaction
ptk/UpdateEvent
(update [_ state]
(log/info :hint "start-undo-transaction")
(update state :workspace-undo
(fn [undo-state]
(-> undo-state
;; Commits made while it is open without an undo group of
;; their own get this one
(update :transaction #(d/nilv % (assoc empty-tx :commit-group (uuid/next))))
(update :transactions-pending assoc id (ct/now))))))
ptk/WatchEvent
(watch [_ _ stream]
(when (and timeout (pos? timeout))
(let [stoper (rx/filter (ptk/type? ::start-undo-transaction) stream)]
(->> (rx/of (check-open-transactions timeout))
;; Wait the configured time
(rx/delay timeout)
(rx/take-until stoper)))))))
(defn discard-undo-transaction
"Updates the state to discard any current and pending undo transaction."
[]
(ptk/reify ::discard-undo-transaction
ptk/UpdateEvent
(update [_ state]
(log/info :hint "discard-undo-transaction")
(update state :workspace-undo dissoc :transaction :transactions-pending))))
(defn- add-transaction-undo-entry
"Conditionally add an undo entry from the current transaction. That
only happens when no pending transactions are available and the
current transaction exists."
[state]
(let [undo-state (get state :workspace-undo)
current-tx (get undo-state :transaction)
pending-tx (get undo-state :transactions-pending)]
(if (and (some? current-tx)
(empty? pending-tx))
(-> state
(add-undo-entry (dissoc current-tx :commit-group))
(update :workspace-undo dissoc :transaction))
state)))
(defn commit-undo-transaction
[id]
(ptk/reify ::commit-undo-transaction
ptk/UpdateEvent
(update [_ state]
(log/info :hint "commit-undo-transaction")
(-> state
(update-in [:workspace-undo :transactions-pending] dissoc id)
(add-transaction-undo-entry)))))
(defn forget-undo-groups
"Drops the undo history from the first entry of `undo-groups` on, or all
of it if none matches, and empties the open transaction. Later commits
of that transaction keep the undo group of its earlier ones."
[undo-groups]
(ptk/reify ::forget-undo-groups
ptk/UpdateEvent
(update [_ state]
(update state :workspace-undo
(fn [{:keys [items index transaction] :as undo}]
(let [first-index (d/index-of-pred items #(contains? undo-groups (:undo-group %)))]
(cond-> undo
(some? first-index)
(assoc :items (subvec items 0 first-index)
:index (min (d/nilv index -1) (dec first-index)))
(nil? first-index)
(dissoc :items :index)
(some? transaction)
(assoc :transaction
(assoc empty-tx :commit-group (or (:undo-group transaction)
(:commit-group transaction)))))))))))
(def reinitialize-undo
"Clears the undo stack, removing all entries and transactions."
(ptk/reify ::reset-undo
ptk/UpdateEvent
(update [_ state]
(assoc state :workspace-undo {}))))
(defn check-open-transactions
[timeout]
(ptk/reify ::check-open-transactions
ptk/WatchEvent
(watch [_ state _]
(log/info :hint "check-open-transactions" :timeout timeout)
(let [pending-ts (-> (dm/get-in state [:workspace-undo :transactions-pending])
(update-vals #(ct/diff-ms % (ct/now))))]
(->> pending-ts
(filter (fn [[_ ts]] (>= ts timeout)))
(rx/from)
(rx/tap #(js/console.warn (dm/str "FORCE COMMIT TRANSACTION AFTER " (second %) "MS")))
(rx/map first)
(rx/map commit-undo-transaction))))))
(defn- restore-selection
"Restores the selection state from an undo entry."
[selected-ids]
(ptk/reify ::restore-selection
ptk/UpdateEvent
(update [_ state]
(if (some? selected-ids)
(assoc-in state [:workspace-local :selected] selected-ids)
state))))
(defn undo-to-index
"Repeat undoing or redoing until dest-index is reached."
[dest-index]
(ptk/reify ::undo-to-index
ptk/WatchEvent
(watch [it state _]
(let [objects (dsh/lookup-page-objects state)
edition (get-in state [:workspace-local :edition])
drawing (get state :workspace-drawing)]
(when-not (and (or (some? edition) (some? (:object drawing)))
(not (ctl/grid-layout? objects edition)))
(let [undo (:workspace-undo state)
items (:items undo)
index (or (:index undo) (dec (count items)))]
(when (and (some? items)
(<= -1 dest-index (dec (count items))))
(let [changes (vec (apply concat
(cond
(< dest-index index)
(->> (subvec items (inc dest-index) (inc index))
(reverse)
(map :undo-changes))
(> dest-index index)
(->> (subvec items (inc index) (inc dest-index))
(map :redo-changes))
:else [])))]
(when (seq changes)
(rx/of (materialize-undo changes dest-index)
(dch/commit-changes {:redo-changes changes
:undo-changes []
:origin it
:save-undo? false})))))))))))
(declare ^:private assure-valid-current-page)
(def undo
"Undo the last action, or the last action in a group.
If there is an open transaction, it will undo to the last transaction
index."
(ptk/reify ::undo
ptk/WatchEvent
(watch [it state _]
(let [objects (dsh/lookup-page-objects state)
edition (get-in state [:workspace-local :edition])
drawing (get state :workspace-drawing)]
;; Editors handle their own undo's
(when (or (and (nil? edition) (nil? (:object drawing)))
(ctl/grid-layout? objects edition))
(let [undo (:workspace-undo state)
items (:items undo)
index (or (:index undo) (dec (count items)))]
(when-not (or (empty? items) (= index -1))
(let [item (get items index)
changes (:undo-changes item)
undo-group (:undo-group item)
find-first-group-idx
(fn [index]
(if (= (dm/get-in items [index :undo-group]) undo-group)
(recur (dec index))
(inc index)))
undo-group-index
(when undo-group
(find-first-group-idx index))]
(if undo-group
(let [first-item (get items undo-group-index)]
(rx/of (undo-to-index (dec undo-group-index))
(restore-selection (:selected-before first-item))))
(rx/of (materialize-undo changes (dec index))
(dch/commit-changes {:redo-changes changes
:undo-changes []
:save-undo? false
:origin it})
(restore-selection (:selected-before item))
(assure-valid-current-page)))))))))))
(def redo
(ptk/reify ::redo
ptk/WatchEvent
(watch [it state _]
(let [objects (dsh/lookup-page-objects state)
edition (get-in state [:workspace-local :edition])
drawing (get state :workspace-drawing)]
;; Editors handle their own undo's
(when (or (and (nil? edition) (nil? (:object drawing)))
(ctl/grid-layout? objects edition))
(let [undo (:workspace-undo state)
items (:items undo)
index (or (:index undo) (dec (count items)))]
(when-not (or (empty? items) (= index (dec (count items))))
(let [item (get items (inc index))
changes (:redo-changes item)
undo-group (:undo-group item)
find-last-group-idx (fn flgidx [index]
(let [item (get items index)]
(if (= (:undo-group item) undo-group)
(flgidx (inc index))
(dec index))))
redo-group-index (when undo-group
(find-last-group-idx (inc index)))]
(if undo-group
(let [last-item (get items redo-group-index)]
(rx/of (undo-to-index redo-group-index)
(restore-selection (:selected-after last-item))))
(rx/of (materialize-undo changes (inc index))
(dch/commit-changes {:redo-changes changes
:undo-changes []
:origin it
:save-undo? false})
(restore-selection (:selected-after item))))))))))))
(defn- assure-valid-current-page
[]
(ptk/reify ::assure-valid-current-page
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
pages (-> (dsh/lookup-file-data state)
(get :pages))]
(if (contains? pages page-id)
(rx/empty)
(rx/of (dcm/go-to-workspace :page-id (first pages))))))))