penpot/frontend/src/app/main/data/changes.cljs
alonso.torres 95584ea916 🐛 Refuse every edit while saves fail
While saves fail the workspace turns read-only, but only some tools
checked it. Edits from the asset, color and token panels, and a drag or
a drawing in progress when the lock came, still made commits that
queued behind the failed save.

commit-changes refuses commits while the save-failure lock holds,
except those that are not user edits: the undo of rolled-back commits,
text position data and component touches. Undo and redo do nothing
while locked. When the lock comes, moves, resizes, rotations, drawings
and the drags of flex, guide and grid handles in progress are cancelled
and commit nothing, and the design panel shows the Inspect view.
2026-11-05 10:56:46 +01:00

332 lines
13 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.persistence.lock :as plk]
[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 (js/Map.)
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 (es6-iterator-seq (.entries 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? undo-index]}]
(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?
;; Undo history index an undo or redo commit moved away from
:undo-index undo-index}]
(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.
Not a commit, so commit watchers miss it: once it has run, its deref
gives `{:file-id :changes :failed}` (`:failed` true when the file
reloads instead)."
[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
IDeref
(-deref [_]
{:file-id file-id
:changes (:redo-changes commit)
:failed @failed?})
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.
- ignore-save-lock: commit even while the save-failure lock holds. Only for
commits that are not user edits: the undo of rolled-back
commits and data derived from commits already made.
"
[{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id
translation? skip-component-sync? ignore-save-lock]
: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.
;; While saves fail, a user edit would queue behind the failed 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]))
(or ignore-save-lock (not (plk/locked? state))))
(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))))))