♻️ Refactor version preview/restore flow

Separate enter-preview and enter-restore flows with dedicated dialogs
instead of a persistent banner. Removes preview-banner component in favor
of inline actions dialog. Uses backup/restore pattern for exit-preview
instead of full workspace reinitialization. Adds analytics events for
preview/restore actions.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
Andrey Antukh 2026-04-23 16:21:37 +02:00
parent e83efadc61
commit c1f587d0da
11 changed files with 207 additions and 255 deletions

View File

@ -70,6 +70,7 @@
(assoc :version (:version snapshot))
(assoc :features (:features snapshot))
(assoc :revn (:revn snapshot))
(assoc :vern (rand-int 100000))
(assoc :permissions perms))))))
(def ^:private schema:create-file-snapshot

View File

@ -231,22 +231,6 @@
(assoc :thumbnails thumbnails)
(update :files assoc file-id file)))))
(defn apply-snapshot-data
"Swap the file data in app state with the provided snapshot-file
response. Used by the version preview feature to show historical
file content without modifying the database.
Also sets workspace-file-version-id to snapshot-id so the WASM
viewport detects the change and reloads shapes into its internal
buffer (it only re-initialises when this value changes)."
[file-id snapshot-id snapshot-file]
(ptk/reify ::apply-snapshot-data
ptk/UpdateEvent
(update [_ state]
(-> state
(update :files assoc file-id snapshot-file)
(assoc :workspace-file-version-id snapshot-id)))))
(defn zoom-to-frame
[]
(ptk/reify ::zoom-to-frame

View File

@ -13,13 +13,16 @@
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dwp]
[app.main.data.workspace :as dw]
[app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.thumbnails :as th]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -125,32 +128,6 @@
(rx/take 1)
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id snapshot-id}))))
(defn restore-version
[id origin]
(assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::restore-version
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
team-id (:current-team-id state)
event-name (case origin
:version "restore-pin-version"
:snapshot "restore-autosave"
:plugin "restore-version-plugin")]
(rx/concat
(rx/of ::dwp/force-persist
(dw/remove-layout-flag :document-history))
(->> (wait-for-persistence file-id id)
(rx/map #(initialize-version)))
(if event-name
(rx/of (ev/event {::ev/name event-name
:file-id file-id
:team-id team-id}))
(rx/empty)))))))
(defn delete-version
[id]
(assert (uuid? id) "expected valid uuid for `id`")
@ -196,73 +173,145 @@
(->> (rp/cmd! :unlock-file-snapshot {:id id})
(rx/map fetch-versions)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; RESTORE VERSION EVENTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- restore-version
[id]
(assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::restore-version
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)]
(rx/concat
(rx/of ::dwp/force-persist
(dw/remove-layout-flag :document-history))
(->> (wait-for-persistence file-id id)
(rx/map #(initialize-version))))))))
(defn enter-restore
[id]
(assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::restore-version
ptk/WatchEvent
(watch [_ state _]
(let [output-s (rx/subject)]
(rx/merge
output-s
(rx/of (ntf/dialog
:content (tr "workspace.versions.restore-warning")
:controls :inline-actions
:cancel {:label (tr "workspace.updates.dismiss")
:callback #(do
(rx/push! output-s (ntf/hide :tag :restore-dialog))
(rx/end! output-s))}
:accept {:label (tr "labels.restore")
:callback #(do
(rx/push! output-s (restore-version id))
(rx/end! output-s))}
:tag :restore-dialog)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREVIEW VERSION EVENTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn preview-version
"Load a snapshot into the workspace for read-only preview without
modifying any database state. Sets a read-only flag so no changes
are persisted while previewing."
[id]
(assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::preview-version
(defn- apply-snapshot
"Swap the file data in app state with the provided snapshot-file
response. Used by the version preview feature to show historical
file content without modifying the database"
[{:keys [id] :as snapshot}]
(ptk/reify ::apply-snapshot-data
ptk/UpdateEvent
(update [_ state]
(-> state
(update :workspace-versions assoc :preview-id id)
(update :workspace-global assoc :read-only? true)))
(update state :files assoc id snapshot))))
(defn exit-preview
"Exit from preview mode and reload the live file data"
[]
(ptk/reify ::exit-preview
ptk/UpdateEvent
(update [_ state]
(let [backup (dm/get-in state [:workspace-versions :backup])]
(-> state
(update :workspace-versions dissoc :backup)
(update :workspace-global dissoc :read-only? :preview-id)
(update :files assoc (:id backup) backup))))
ptk/WatchEvent
(watch [_ state _]
(let [team-id (:current-team-id state)
file-id (:current-file-id state)
page-id (:current-page-id state)]
(rx/of (dwpg/initialize-page file-id page-id))))))
(defn enter-preview
"Load a snapshot into the workspace for read-only preview without
modifying any database state. Sets a read-only flag so no changes
are persisted while previewing and enter on the preview mode"
[id]
(assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::enter-preview
ptk/UpdateEvent
(update [_ state]
(let [file (dsh/lookup-file state)]
(-> state
(update :workspace-versions assoc :backup file)
(update :workspace-global assoc :read-only? true :preview-id id))))
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
page-id (:current-page-id state)
features (get-in state [:files file-id :features])]
(->> (rp/cmd! :get-file-snapshot
{:file-id file-id
:id id
:features features})
(rx/mapcat
(fn [snapshot-file]
(rx/of
;; Swap the file data in state with snapshot content.
;; Passing id sets workspace-file-version-id, which
;; causes the WASM viewport to reload its shape buffer.
(dw/apply-snapshot-data file-id id snapshot-file)
;; Re-initialize the page to rebuild its search index
;; and page-local state with the new snapshot objects.
(dw/initialize-page file-id page-id))))
(rx/catch (fn [err]
;; On error roll back the read-only flag so the
;; user is not stuck in a broken preview state.
(log/error :hint "failed to load snapshot" :cause err :file-id file-id :snapshot-id id)
(rx/of (update-versions-state {:preview-id nil})
(ptk/reify ::clear-preview-read-only
ptk/UpdateEvent
(update [_ state]
(update state :workspace-global dissoc :read-only?)))))))))))
team-id (:current-team-id state)
features (features/get-enabled-features state team-id)
snapshot (->> (dm/get-in state [:workspace-versions :data])
(d/seek #(= id (:id %))))
label (or (:label snapshot)
(tr "workspace.versions.preview.unnamed"))
output-s (rx/subject)]
(rx/merge
output-s
(defn exit-preview
"Exit version preview mode and reload the live file data."
[]
(ptk/reify ::exit-preview
ptk/UpdateEvent
(update [_ state]
(-> state
(update :workspace-versions dissoc :preview-id)
(update :workspace-global dissoc :read-only?)
;; A fresh UUID triggers the WASM viewport to reload its shape
;; buffer with the live file objects once initialize-workspace
;; completes.
(assoc :workspace-file-version-id (uuid/next))))
(rx/of (ntf/dialog
:content (tr "workspace.versions.preview-banner-title" label)
:controls :inline-actions
:cancel {:label (tr "labels.exit")
:callback #(do
(rx/push! output-s (ntf/hide))
(rx/push! output-s (exit-preview))
(rx/end! output-s))}
:accept {:label (tr "labels.restore")
:callback #(do
(rx/push! output-s (ntf/hide))
(rx/push! output-s (restore-version id))
(rx/end! output-s))}
:tag :preview-dialog))
ptk/WatchEvent
(watch [_ state _]
(let [team-id (:current-team-id state)
file-id (:current-file-id state)]
;; Full workspace re-init reloads the live file from the server,
;; clearing all snapshot data and restoring normal edit mode.
(rx/of (dw/initialize-workspace team-id file-id))))))
(->> (rp/cmd! :get-file-snapshot
{:file-id file-id
:id id
:features features})
(rx/mapcat
(fn [snapshot]
(rx/of
;; Swap the file data in state with snapshot content.
;; Passing id sets workspace-file-version-id, which
;; causes the WASM viewport to reload its shape buffer.
(apply-snapshot snapshot)
;; Re-initialize the page to rebuild its search index
;; and page-local state with the new snapshot
;; objects.
(dwpg/initialize-page file-id page-id))))
(rx/catch (fn [err]
;; On error roll back the read-only flag so the
;; user is not stuck in a broken preview state.
(log/error :hint "failed to load snapshot" :cause err :file-id file-id :snapshot-id id)
(rx/of (exit-preview))))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PLUGINS SPECIFIC EVENTS
@ -320,9 +369,8 @@
(watch [_ state _]
(let [team-id (:current-team-id state)]
(rx/concat
(rx/of (ev/event {::ev/name "restore-version-plugin"
:file-id file-id
:team-id team-id})
(rx/of (ev/event {::ev/name "restore-version"
::ev/origin "plugins"})
::dwp/force-persist)
(->> (wait-for-persistence file-id id)

View File

@ -134,54 +134,6 @@
:overlay true
:file-loading true}])
(def ^:private workspace-versions-ref
(l/derived :workspace-versions st/state))
(mf/defc preview-banner*
"Banner shown at the top of the workspace when the user is previewing
a saved version. Provides Exit and Restore actions."
{::mf/private true}
[]
(let [versions-state (mf/deref workspace-versions-ref)
preview-id (:preview-id versions-state)
preview-entry (when preview-id
(d/seek #(= (:id %) preview-id) (:data versions-state)))
preview-label (or (:label preview-entry)
(tr "workspace.versions.preview.unnamed"))
on-exit
(mf/use-fn
(fn []
(st/emit! (dwv/exit-preview))))
on-restore
(mf/use-fn
(mf/deps preview-id)
(fn []
(when preview-id
(st/emit!
(ntf/dialog
:content (tr "workspace.versions.restore-warning")
:controls :inline-actions
:cancel {:label (tr "workspace.updates.dismiss")
:callback #(st/emit! (ntf/hide))}
:accept {:label (tr "labels.restore")
:callback #(st/emit! (ntf/hide)
(dwv/exit-preview)
(dwv/restore-version preview-id :version))}
:tag :restore-dialog)))))]
(when preview-id
[:div {:class (stl/css :preview-banner)}
[:span {:class (stl/css :preview-banner-label)}
(tr "workspace.versions.preview.banner-label" preview-label)]
[:button {:class (stl/css :preview-banner-exit-btn)
:on-click on-exit}
(tr "workspace.versions.preview.exit")]
[:button {:class (stl/css :preview-banner-restore-btn)
:on-click on-restore}
(tr "labels.restore")]])))
(defn- make-team-ref
[team-id]
(l/derived (fn [state]
@ -319,7 +271,6 @@
:touch-action "none"
:position "relative"}}
[:> context-menu*]
[:> preview-banner*]
(when (and file-loaded? page-id)
[:> workspace-inner*
{:page-id page-id

View File

@ -54,53 +54,3 @@
grid-template-columns: deprecated.$s-20 1fr;
flex: 1;
}
.preview-banner {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: var(--z-index-modal);
background: var(--color-accent-primary);
color: var(--color-background-primary);
display: flex;
align-items: center;
gap: deprecated.$s-12;
padding: deprecated.$s-8 deprecated.$s-16;
border-radius: 0 0 deprecated.$s-8 deprecated.$s-8;
white-space: nowrap;
box-shadow: 0 deprecated.$s-4 deprecated.$s-16 rgb(0 0 0 / 0.25);
}
.preview-banner-label {
font-size: deprecated.$fs-12;
font-weight: 600;
}
.preview-banner-exit-btn,
.preview-banner-restore-btn {
border: none;
border-radius: deprecated.$s-4;
cursor: pointer;
padding: deprecated.$s-4 deprecated.$s-8;
font-size: deprecated.$fs-11;
}
.preview-banner-exit-btn {
background: rgb(255 255 255 / 0.2);
color: inherit;
&:hover {
background: rgb(255 255 255 / 0.35);
}
}
.preview-banner-restore-btn {
background: rgb(255 255 255 / 0.9);
color: var(--color-accent-primary);
font-weight: 600;
&:hover {
background: rgb(255 255 255);
}
}

View File

@ -11,7 +11,7 @@
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.main.data.notifications :as ntf]
[app.main.data.event :as ev]
[app.main.data.workspace.versions :as dwv]
[app.main.refs :as refs]
[app.main.store :as st]
@ -77,17 +77,6 @@
(assoc item :index index)))
(reverse)))
(defn- open-restore-version-dialog
[origin id]
(st/emit! (ntf/dialog
:content (tr "workspace.versions.restore-warning")
:controls :inline-actions
:cancel {:label (tr "workspace.updates.dismiss")
:callback #(st/emit! (ntf/hide))}
:accept {:label (tr "labels.restore")
:callback #(st/emit! (dwv/restore-version id origin))}
:tag :restore-dialog)))
(mf/defc version-entry*
{::mf/private true}
[{:keys [entry current-profile on-preview on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}]
@ -352,22 +341,34 @@
on-preview-version
(mf/use-fn
(fn [id]
(st/emit! (dwv/preview-version id))))
(st/emit! (dwv/enter-preview id)
(ev/event {::ev/name "preview-version"
::ev/origin "workspace:sidebar"
:type "pinned-version"}))))
on-preview-snapshot
(mf/use-fn
(fn [id _event]
(st/emit! (dwv/preview-version id))))
(st/emit! (dwv/enter-preview id)
(ev/event {::ev/name "preview-version"
::ev/origin "workspace:sidebar"
:type "autosaved-version"}))))
on-restore-version
(mf/use-fn
(fn [id _event]
(open-restore-version-dialog :version id)))
(st/emit! (dwv/enter-restore id)
(ev/event {::ev/name "restore-version"
::ev/origin "workspace:sidebar"
:type "pinned-version"}))))
on-restore-snapshot
(mf/use-fn
(fn [id _event]
(open-restore-version-dialog :snapshot id)))
(st/emit! (dwv/enter-restore id)
(ev/event {::ev/name "restore-version"
::ev/origin "workspace:sidebar"
:type "autosaved-version"}))))
on-delete-version
(mf/use-fn

View File

@ -97,6 +97,7 @@
{:keys [options-mode
tooltip
preview-id
show-distances?
picking-color?]}
wglobal
@ -313,23 +314,28 @@
(hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?)
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
[:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"}
(when (:can-edit permissions)
(if read-only?
[:> view-only-bar* {}]
[:*
(when-not hide-ui?
[:> top-toolbar* {:layout layout}])
[:div {:class (stl/css :viewport) :style {"--zoom" zoom} :data-testid "viewport"}
(cond
(some? preview-id)
nil
(when (and ^boolean path-editing?
^boolean single-select?)
[:> path-edition-bar* {:shape editing-shape
:edit-path-state edit-path-state
:layout layout}])
(and read-only? (:can-edit permissions))
[:> view-only-bar* {}]
(when (and ^boolean grid-editing?
^boolean single-select?)
[:> grid-edition-bar* {:shape editing-shape}])]))
:else
[:*
(when-not hide-ui?
[:> top-toolbar* {:layout layout}])
(when (and ^boolean path-editing?
^boolean single-select?)
[:> path-edition-bar* {:shape editing-shape
:edit-path-state edit-path-state
:layout layout}])
(when (and ^boolean grid-editing?
^boolean single-select?)
[:> grid-edition-bar* {:shape editing-shape}])])
[:div {:class (stl/css :viewport-overlays)}
;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap

View File

@ -20,14 +20,12 @@
;; branch.
(mf/defc view-only-bar*
{::mf/private true}
[]
(let [handle-close-view-mode
(let [on-close
(mf/use-fn
(fn []
(st/emit! :interrupt
(dw/set-options-mode :design)
(dwc/set-workspace-read-only false))))]
#(st/emit! :interrupt
(dw/set-options-mode :design)
(dwc/set-workspace-read-only false)))]
[:div {:class (stl/css :viewport-actions)}
[:div {:class (stl/css :viewport-actions-container)}
[:div {:class (stl/css :viewport-actions-title)}
@ -35,7 +33,7 @@
{:tag-name "span"
:content (tr "workspace.top-bar.view-only")}]]
[:button {:class (stl/css :done-btn)
:on-click handle-close-view-mode}
:on-click on-close}
(tr "workspace.top-bar.read-only.done")]]]))
(mf/defc path-edition-bar*

View File

@ -98,6 +98,7 @@
{:keys [options-mode
tooltip
show-distances?
preview-id
picking-color?]}
wglobal
@ -454,22 +455,28 @@
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
[:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"}
(when (:can-edit permissions)
(if read-only?
[:> view-only-bar* {}]
[:*
(when-not hide-ui?
[:> top-toolbar* {:layout layout}])
(when (and ^boolean path-editing?
^boolean single-select?)
[:> path-edition-bar* {:shape editing-shape
:edit-path-state edit-path-state
:layout layout}])
(cond
(some? preview-id)
nil
(when (and ^boolean grid-editing?
^boolean single-select?)
[:> grid-edition-bar* {:shape editing-shape}])]))
(and read-only? (:can-edit permissions))
[:> view-only-bar* {}]
:else
[:*
(when-not hide-ui?
[:> top-toolbar* {:layout layout}])
(when (and ^boolean path-editing?
^boolean single-select?)
[:> path-edition-bar* {:shape editing-shape
:edit-path-state edit-path-state
:layout layout}])
(when (and ^boolean grid-editing?
^boolean single-select?)
[:> grid-edition-bar* {:shape editing-shape}])])
[:div {:class (stl/css :viewport-overlays)}
(when show-comments?

View File

@ -2960,6 +2960,9 @@ msgstr "Resend invitation"
msgid "labels.restore"
msgstr "Restore"
msgid "labels.exit"
msgstr "Exit"
#: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, src/app/main/ui/static.cljs:419
msgid "labels.retry"
msgstr "Retry"
@ -8878,11 +8881,8 @@ msgstr "This version is locked by %s and cannot be modified"
msgid "workspace.versions.locked-by-you"
msgstr "This version is locked by you"
msgid "workspace.versions.preview.banner-label"
msgstr "Previewing: %s"
msgid "workspace.versions.preview.exit"
msgstr "Exit preview"
msgid "workspace.versions.preview-banner-title"
msgstr "Previewing version: %s"
msgid "workspace.versions.preview.unnamed"
msgstr "Unnamed version"

View File

@ -2907,6 +2907,9 @@ msgstr "Reenviar invitacion"
msgid "labels.restore"
msgstr "Restaurar"
msgid "labels.exit"
msgstr "Salir"
#: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, src/app/main/ui/static.cljs:419
msgid "labels.retry"
msgstr "Reintentar"
@ -8726,6 +8729,9 @@ msgstr "Versiones de %s"
msgid "workspace.versions.loading"
msgstr "Cargando..."
msgid "workspace.versions.preview-banner-title"
msgstr "Previsualizando version: %s"
#: src/app/main/ui/workspace/sidebar/versions.cljs:83
msgid "workspace.versions.restore-warning"
msgstr "¿Quieres restaurar esta versión?"