mirror of
https://github.com/penpot/penpot.git
synced 2026-11-04 17:10:39 +00:00
The backend rejects a save after the call that made the changes has returned, and the workspace then undoes them. A plugin, or the MCP agent, went on working with shapes that were no longer there and never learned why. The rollback event now names the files that lost commits and the pages and page shapes their changes touched, and plugins get it as the new `contentrollback` event (`ContentRollback` type) through a subscription to the store stream, so no rollback is dropped by debouncing. The MCP plugin keeps the rollbacks it is told about and sends them with its next task response, failed or not. The server collects them per tool call and puts a warning first in that tool's response, naming what was affected; the instructions tell the agent to read the pages again and redo what is missing.
163 lines
5.4 KiB
Clojure
163 lines
5.4 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.plugins.events
|
|
(:require
|
|
[app.common.data.macros :as dm]
|
|
[app.main.data.helpers :as dsh]
|
|
[app.main.data.persistence :as dps]
|
|
[app.main.store :as st]
|
|
[app.plugins.file :as file]
|
|
[app.plugins.page :as page]
|
|
[app.plugins.parser :as parser]
|
|
[app.plugins.shape :as shape]
|
|
[app.util.object :as obj]
|
|
[app.util.theme :as theme]
|
|
[beicon.v2.core :as rx]
|
|
[goog.functions :as gf]
|
|
[potok.v2.core :as ptk]))
|
|
|
|
(defmulti handle-state-change (fn [type _ _ _ _] type))
|
|
|
|
(defmethod handle-state-change "finish"
|
|
[_ _ old-val new-val _]
|
|
(let [old-file-id (:current-file-id old-val)
|
|
new-file-id (:current-file-id new-val)]
|
|
(if (and (some? old-file-id) (nil? new-file-id))
|
|
(str old-file-id)
|
|
::not-changed)))
|
|
|
|
(defmethod handle-state-change "filechange"
|
|
[_ plugin-id old-val new-val _]
|
|
(let [old-file-id (:current-file-id old-val)
|
|
new-file-id (:current-file-id new-val)]
|
|
(if (identical? old-file-id new-file-id)
|
|
::not-changed
|
|
(file/file-proxy plugin-id new-file-id))))
|
|
|
|
(defmethod handle-state-change "pagechange"
|
|
[_ plugin-id old-val new-val _]
|
|
(let [old-page-id (:current-page-id old-val)
|
|
new-page-id (:current-page-id new-val)]
|
|
(if (identical? old-page-id new-page-id)
|
|
::not-changed
|
|
(page/page-proxy plugin-id (:current-file-id new-val) new-page-id))))
|
|
|
|
(defmethod handle-state-change "selectionchange"
|
|
[_ _ old-val new-val _]
|
|
(let [old-selection (get-in old-val [:workspace-local :selected])
|
|
new-selection (get-in new-val [:workspace-local :selected])]
|
|
(if (identical? old-selection new-selection)
|
|
::not-changed
|
|
(apply array (map str new-selection)))))
|
|
|
|
(defn- get-theme
|
|
[state]
|
|
(theme/resolve-theme (get-in state [:profile :theme])
|
|
(theme/get-system-theme)))
|
|
|
|
(defmethod handle-state-change "themechange"
|
|
[_ _ old-val new-val _]
|
|
(let [old-theme (get-theme old-val)
|
|
new-theme (get-theme new-val)]
|
|
(if (identical? old-theme new-theme)
|
|
::not-changed
|
|
new-theme)))
|
|
|
|
(defmethod handle-state-change "shapechange"
|
|
[_ plugin-id old-val new-val {:keys [shape-id]}]
|
|
(if (some? shape-id)
|
|
(let [old-shape (dsh/lookup-shape old-val shape-id)
|
|
new-shape (dsh/lookup-shape new-val shape-id)
|
|
|
|
file-id (:current-file-id new-val)
|
|
page-id (:current-page-id new-val)]
|
|
(if (and (identical? old-shape new-shape) (some? plugin-id) (some? file-id) (some? page-id))
|
|
::not-changed
|
|
(shape/shape-proxy plugin-id file-id page-id shape-id)))
|
|
::not-changed))
|
|
|
|
(defmethod handle-state-change "contentsave"
|
|
[_ _ old-val new-val _]
|
|
(let [old-status (dm/get-in old-val [:persistence :status])
|
|
new-status (dm/get-in new-val [:persistence :status])]
|
|
(if (and (= :saved new-status) (not= new-status old-status))
|
|
::void ;; Changed but void
|
|
::not-changed)))
|
|
|
|
(defmethod handle-state-change :default
|
|
[_ _ _ _ _]
|
|
::not-changed)
|
|
|
|
(defn- parse-props
|
|
"Resolves the listener options. Runs at registration, so a malformed
|
|
value raises where the plugin can see it."
|
|
[props]
|
|
{:shape-id (-> (obj/get props "shapeId") parser/parse-id)})
|
|
|
|
;; Stream subscriptions of the listeners that follow events, not state
|
|
(defonce ^:private subscriptions (atom {}))
|
|
|
|
(defn- format-rollback
|
|
[{:keys [file-ids page-ids shape-ids]}]
|
|
#js {:fileIds (apply array (map str file-ids))
|
|
:pageIds (apply array (map str page-ids))
|
|
:shapeIds (apply array (map str shape-ids))})
|
|
|
|
(defn- subscribe-rollbacks
|
|
"Calls `callback` after each rollback of rejected changes, once the
|
|
reverted data is in the state. Not debounced: every rollback counts."
|
|
[callback]
|
|
(->> st/stream
|
|
(rx/filter (ptk/type? ::dps/rolled-back))
|
|
(rx/map (comp format-rollback deref))
|
|
(rx/observe-on :async)
|
|
(rx/subs! callback)))
|
|
|
|
(defn add-listener
|
|
[type plugin-id callback props]
|
|
(let [plugin-id (parser/parse-id plugin-id)
|
|
props (parse-props props)
|
|
key (js/Symbol)
|
|
|
|
;; We wrap the callback in an exception handler so the plugins
|
|
;; don't crash the application
|
|
safe-callback
|
|
(fn [value]
|
|
(try
|
|
(if (= ::void value)
|
|
(callback)
|
|
(callback value))
|
|
(catch :default cause
|
|
(.error js/console cause))))
|
|
|
|
;; We also debounce the callbacks so we don't get too many at the same time
|
|
debounced-callback (gf/debounce safe-callback 10)]
|
|
|
|
(if (= "contentrollback" type)
|
|
(swap! subscriptions assoc key (subscribe-rollbacks safe-callback))
|
|
(add-watch
|
|
st/state key
|
|
(fn [_ _ old-val new-val]
|
|
;; The store notifies its watches with a plain iteration: a throw
|
|
;; here starves the watches registered after this one.
|
|
(try
|
|
(let [result (handle-state-change type plugin-id old-val new-val props)]
|
|
(when (not= ::not-changed result)
|
|
(debounced-callback result)))
|
|
(catch :default cause
|
|
(.error js/console cause))))))
|
|
|
|
;; return the generated key
|
|
key))
|
|
|
|
(defn remove-listener
|
|
[key]
|
|
(when-let [subscription (get @subscriptions key)]
|
|
(rx/dispose! subscription)
|
|
(swap! subscriptions dissoc key))
|
|
(remove-watch st/state key))
|