🐛 Fix small save bugs found by the rollback test plan

- Ending a drag inside the frame the shapes already belong to sent an
  empty reparent save and bumped the revision. The move commits only
  when some shape changes parent.
- When a user restored a version, other sessions reloaded the file with
  no notice. They now see a dialog naming who restored which version
  (by its name, else its date), saying that later changes are no longer
  in the file and, when they had changes not saved yet, that those were
  lost.
- Exports drawn in the browser (PDF, SVG, and PNG when the WASM renderer
  draws it) and duplicating a file from the dashboard started at once,
  so an export could show changes a pending save then rejected, and a
  copy could miss queued edits. Both wait for the file's pending saves.
  A client export warns that it includes unsaved changes; a copy warns
  that it misses them.
This commit is contained in:
alonso.torres 2026-10-20 16:19:56 +02:00
parent a1131cc36c
commit 882645500b
21 changed files with 694 additions and 56 deletions

View File

@ -25,7 +25,7 @@
- A save answered with `:rejected` (backend kept the commits before the first failing one, see `mem:backend/subtleties`) is not a failure: `rollback-rejected` runs before `commit-persisted`. The unit is the undo group (one user action, which can commit to several files, e.g. `update-component` writes the copy's file and the library, and `component-changed` syncs inherit the edit's group; multi-file actions must share a non-nil group): the pure bookkeeping lives in `app.main.data.persistence.commits` (`pcm`): `pcm/rollback-plan` reverts, in the saved file, the pending commits from the first rejected one on, and in any file the pending commits from the first one of a reverted group on, iterating until no new group appears; other files' unrelated commits stay. `pcm/without-rolled-back` drops pending reverted commits from queue entries (partial entries trimmed, empty ones leave the queue) and the buffer; saved commits of a reverted group (the head's kept ones, and `:saved`, which `keep-saved-commits` fills on `discard-commit` with saved commits whose group a pending commit still shares) are undone by a new `save-undo? false` commit (`compensate-commits`, flushed like any edit); for a file whose data is not loaded the undo goes straight to the queue as an entry, nothing applied locally. Per file, `dch/rebase-commits` undoes every pending commit newest first, re-applies the remote changes kept since the oldest pending commit, and redoes the pending commits that stay. Undo changes hold absolute values from commit time, so undoing only the rejected commits would overwrite a peer's later change to the same attr. Remote changes are numbered (`record-remote-changes`, `[:persistence :remote-seq]`) and kept per file under `:remote-changes` only while the file has pending commits; `prune-remote-changes` (discard, release) forgets those at or below the oldest pending stamp, since re-applying them would undo saved edits. Then `dwu/forget-undo-groups groups undo-index` (cut history before the first entry whose `:undo-groups` — every group the entry holds, filled by `append-undo`, transactions and stacking — meets a reverted group; no match keeps the history; the open transaction is emptied only when cut or holding one, keeping its group for later commits; a reverted Ctrl+Z/Ctrl+Shift+Z commit, which carries real `:undo-changes` and `:undo-index` (the index it moved from, kept by `pcm` commit-keys), puts the index back to the plan's `:undo-index`, the oldest one's; background commits with `:undo-changes []` (position-data, component `modified-at`, deleted-font fixes) stay on screen after a rollback by design), selection pruned and edition mode left when the edited shape is gone, or when it is a path being edited that the rollback changed: `discard-path-edit` drops `[:workspace-local :edit-path id]` first, so `finalize-path-content` sees no `:old-content` and the editor's stale copy is never saved (`prune-removed-shapes` takes the edited object from before the rebase to compare). Text editions stay open: the v3 WASM editor already shows the reverted text, and the v2 editor resets its DOM root from the shape on `::rolled-back` when the text differs, since its unmount `on-blur` would otherwise save the stale DOM content (`prune-removed-shapes`; `v2-update-text-shape-content` also skips a missing shape, so the editor's exit commits nothing), `errors/flash` `:unhandled` toast tagged `:persistence-rejected`, hidden after `rejected-timeout-ms` (10 s). It ends with `(ptk/data-event ::rolled-back {:file-ids})`: `dwl/sync-file` waits for it or `wait-persisted` and marks the library synced only on a landed save (on rollback it restores `:synced-at` and re-checks updates after the rollback's own save). `update-status` ignores `:pending` when buffer and queue are empty (`nothing-to-save?`): a dropped commit's 200 ms pending debounce would otherwise leave the status stuck and `wait-persisted-or-error` waiting forever. `prune-removed-shapes` also closes a `:colorpicker` modal. `rebase-commits` is not a commit (not saved/broadcast/undoable, commit watchers such as thumbnails do not see it) and falls back to `::dw/reload-current-file` if applying throws. `debug.commitInvalidChange({kind, before, after})` forces such a rejection live.
- Persistence sends revn as the max of the commit revn and locally tracked latest revn; remote commits update that revn tracker.
- Persistence is skipped in version preview/read-only mode or without edit permission.
- Failing saves lock the workspace (`app.main.data.persistence.lock`, alias `plk`): `plk/failing?` (status `:error`, or `:recovering` while a slow-cycle attempt is in flight as `:saving`) drives `sync-failure-lock` (also on `change-team-role`), which locks only while `[:permissions :can-edit]`: a viewer, already read-only, gets the `:save-permission-denied` toast and never the save-lock banner; `lock-on-failure` stores the previous read-only value in `[:workspace-global :persistence-lock]` and sets `:read-only?`; `unlock-after-failure` restores it (into `[:render-state :pre-context-lost-read-only?]` instead while the WebGL context is lost, which keeps its own save/restore). `set-workspace-read-only` writes into the lock while it is held, so an Inspect/Design switch is not lost. `view-only-bar*` (`ui/workspace/viewport/top_bar.cljs`, shared by both renderers) shows the save error while the lock exists (text per `dps/save-retrying?`), hides its Done/exit button and takes priority over the context-loss message. No save-failure popup: page navigation, pan, zoom and inspection stay available. Once unlocked, the usual Inspect/context-loss message and action return. Plugins still write while read-only (never as a viewer: `commit-changes` drops commits without `:can-edit`). A peer's `:file-restored` reloads the file and opens the `:file-restored` modal named after the restoring session's profile.
- Failing saves lock the workspace (`app.main.data.persistence.lock`, alias `plk`): `plk/failing?` (status `:error`, or `:recovering` while a slow-cycle attempt is in flight as `:saving`) drives `sync-failure-lock` (also on `change-team-role`), which locks only while `[:permissions :can-edit]`: a viewer, already read-only, gets the `:save-permission-denied` toast and never the save-lock banner; `lock-on-failure` stores the previous read-only value in `[:workspace-global :persistence-lock]` and sets `:read-only?`; `unlock-after-failure` restores it (into `[:render-state :pre-context-lost-read-only?]` instead while the WebGL context is lost, which keeps its own save/restore). `set-workspace-read-only` writes into the lock while it is held, so an Inspect/Design switch is not lost. `view-only-bar*` (`ui/workspace/viewport/top_bar.cljs`, shared by both renderers) shows the save error while the lock exists (text per `dps/save-retrying?`), hides its Done/exit button and takes priority over the context-loss message. No save-failure popup: page navigation, pan, zoom and inspection stay available. Once unlocked, the usual Inspect/context-loss message and action return. Plugins still write while read-only (never as a viewer: `commit-changes` drops commits without `:can-edit`). A peer's `:file-restored` (backend adds `:profile-id`, `:created-at` and, for a version a user named, `:label`) reloads the file and opens the `:file-restored` modal: who restored (profile, else the session's presence, else "A teammate"), the version by label, else date (`PPp`), and the "latest changes couldn't be saved" line when `dps/pending-file-changes` held edits of the replaced version, which the reload drops.
- A terminal failure halts the queue on purpose: the edits stay queued so `wait-persisted-or-error` keeps rejecting, and `workspace/versions.cljs` relies on that to avoid snapshotting a file whose edits were never saved. `recover-persistence` lifts the halt when a file is opened again or the role changes. A transport failure still inside its retry window (`slow-retry-pending?`) is resent once (`resume-persistence true`), never released; any other halt emits `release-failed-file` (drops every queued commit of the file that failed) then `resume-persistence`. Opening a file also replays the queued, then buffered, edits of its version onto the loaded data (`pending-file-changes` → `restore-pending-changes` in `bundle-fetched`); a replay that throws keeps the server data and the queue. That is what unblocks the editor without a reload; because the queue is shared, dropping the failed file is also what lets other files' pending edits drain. `release-failed-file` runs at recovery time, never at failure time — clearing the error eagerly would make waiters resolve.
- The split between `initialize-persistence-state` and `recover-persistence` is deliberate: initialization deliberately does NOT lift a halt, so undeliverable edits are released when the user acts, not when the view mounts.
- **Every non-transport terminal save failure is permanent** (a backend without commit-id groups still answers an invalid batch with a terminal `:validation`). There is no "stale but valid" category: permission lost, file gone, and server-rejected changes all fail again on a resend. Losing edit permission is not temporary — the backend raises `:not-found` for it (`check-edition-permissions!` → `perms/make-check-fn`), and a save's `:not-found` is a terminal save error that keeps the workspace open (`errors/delegated-persistence-types` is only `#{:authentication}`; a missing data fragment answers it too). So `:save-permission-denied` (a client-side `can-edit` check) must never be resumed. A role change that leaves `can-edit` false keeps it halted (the edits stay queued and on screen; `notify-kept-failure` shows the count toast again, since the single-toast store lets the role-change info toast replace it); the role change that brings edit rights back releases it. A release on a role change (`release-failed-file` with `revert?`, i.e. not on opening a file, whose data comes fresh from the server) also rolls the released commits back locally with `pcm/release-plan` (a `rollback-plan` rejecting every pending commit of the file) through `apply-rollback-plan`: rebase, compensation of saved commits of the same groups, undo-history cut, `prune-removed-shapes`, `::rolled-back`. Without it the canvas keeps showing edits the server never got. Its toast `errors.save-permission-lost` is plural over `:unsaved-commits` (`count-unsaved-commits`); plural keys need `(tr k (i18n/c n))`, a bare number renders the fallback text.
@ -36,6 +36,7 @@
- `save-request-timeout-ms` is 7 minutes and wraps the `update-file` request in `rx/timeout`, failing it as a transient `:network` error so the retry path resends instead of blocking the queue forever. It is deliberately longer than `saving-stall-timeout-ms` (5 min) so the stalled report fires before the retry.
- `sync-unload-guard` keeps `:persistence` in `st/ongoing-tasks` while edits are buffered or queued, which is what holds the browser leave-page warning on. It reads `[:persistence :buffer]` and `:queue` and syncs on `::buffer-commit`, `::append-commit`, `::discard-commit` and `::release-failed-file` (every buffer and queue change). It is the one effect that must stay in `store.cljs`'s `omitset`, and `store.cljs`'s `ongoing-tasks` watcher is guarded by `exists? js/window` because tests run without a window.
- `leave-signals` (document `visibilitychange`→hidden, window `blur`) emits `persist-buffered`, which forces a save when `[:persistence :buffer]` is not empty, so leaving the tab/window sends the 3 s batch at once; tests mock `dps/leave-signals` BEFORE `initialize-persistence` runs. pagehide/beforeunload are too late for an async save.
- Exports (exporter and client/WASM PDF/SVG/PNG alike), the dashboard `dd/duplicate-files` and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` first. A client export draws local data, so its warning says the export includes unsaved changes (`workspace.export.unsaved-changes-included`); exporter exports and the duplicate copy server data and warn they miss them. `duplicate-files` owns its one toast, shown once every copy exists: the plural `dashboard.duplicate-unsaved-changes` warning when the files had unsaved changes before the copy request, else the success toast; a failed copy errors the stream, so neither shows (the file menu must not emit its own success toast, the single-toast store would let it replace the warning).
- Exports and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` (waits only for those files, ends on `:error` or timeout, never raises) and then `unsaved-changes? state file-ids` to warn; it reads `refs/persistence`, so tests mock it with `(l/derived :persistence store)`.
- `persistence-failed` emits a `save-failed` audit event (`:cause-type :code :retrying :queued-commits`) whenever the user is notified (`notify-failure?`, same rule as the toast), including delegated auth failures.
- `start-undo-transaction` stamps the transaction with a fresh `:commit-group` (dropped from the history entry); `commit-changes` uses `(or undo-group (:undo-group tx) (:commit-group tx) (uuid/next))`, so all commits of one transaction are one rollback unit. Pre-setting `:undo-group` instead breaks entries whose group is set later (sync transactions).

View File

@ -196,13 +196,20 @@
(ex/raise :type :conflict
:code :file-modified
:hint "the file was modified during the restore process, please retry")))
(let [vern (fsnap/restore! cfg file-id id)]
(let [snapshot (fsnap/get-minimal-snapshot cfg id)
vern (fsnap/restore! cfg file-id id)]
;; Peers name the restored version by its label when a
;; user named it, else by its date
(mbus/pub! msgbus
:topic file-id
:message {:type :file-restored
:session-id session-id
:file-id file-id
:vern vern})
:message (cond-> {:type :file-restored
:session-id session-id
:profile-id profile-id
:file-id file-id
:vern vern
:created-at (:created-at snapshot)}
(= "user" (:created-by snapshot))
(assoc :label (:label snapshot))))
nil)))))
(def ^:private schema:update-file-snapshot

View File

@ -23,6 +23,7 @@
[app.features.fdata :as fdata]
[app.features.file-snapshots :as fsnap]
[app.http :as http]
[app.msgbus :as mbus]
[app.rpc :as-alias rpc]
[app.storage :as sto]
[backend-tests.helpers :as th]
@ -92,11 +93,19 @@
::rpc/profile-id (:id profile)
:file-id (:id file)
:id @snapshot-id}
out (th/command! params)]
calls (atom [])
out (with-redefs [mbus/pub! (fn [_cfg & {:keys [message]}]
(swap! calls conj message))]
(th/command! params))
sent (first (filter #(= :file-restored (:type %)) @calls))]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(t/is (nil? (:result out)))
;; Peers learn who restored which version
(t/is (= (:id profile) (:profile-id sent)))
(t/is (= "label1" (:label sent)))
(t/is (ct/inst? (:created-at sent))))
(let [[row1 row2 :as rows]
(th/db-query :file-change

View File

@ -23,6 +23,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dps]
[app.main.data.team :as dtm]
[app.main.data.websocket :as dws]
[app.main.repo :as rp]
@ -535,29 +536,60 @@
(rx/map #(with-meta (file-created %) (meta it)))
(rx/catch on-error))))))
;; --- EVENT: duplicate-file
;; --- EVENT: duplicate-files
(defn duplicate-file
[{:keys [id name] :as params}]
(dm/assert! (uuid? id))
(dm/assert! (string? name))
(ptk/reify ::duplicate-file
;; Longest wait for the pending changes of a file to be saved before copying it.
(def ^:private save-wait-timeout-ms 10000)
(defn- notify-duplicated
"Tells the user the `n` copies exist. With `unsaved`, warns instead that
they miss the changes not saved yet."
[n unsaved]
(if unsaved
(ntf/show {:content (tr "dashboard.duplicate-unsaved-changes" (i18n/c n))
:type :toast
:level :warning
:timeout 8000})
(ntf/success (tr "dashboard.success-duplicate-file" (i18n/c n)))))
(defn- copy-files
"Copies `files` on the server, then tells the user. A copy that fails
ends the stream with its error, so nothing is told."
[files]
(ptk/reify ::copy-files
ptk/WatchEvent
(watch [_ state _]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error rx/throw}} (meta params)
unames (cfh/get-used-names (get state :files))
(let [file-ids (into #{} (map :id) files)
;; The copies hold the server's data, so what is unsaved now is missing
unsaved (dps/unsaved-changes? state file-ids)
unames (cfh/get-used-names (get state :files))
suffix-fn (fn [copy-count]
(str/concat " "
(tr "dashboard.copy-suffix")
(when (> copy-count 1)
(str " " copy-count))))
new-name (cfh/generate-unique-name name unames :suffix-fn suffix-fn)]
(->> (rp/cmd! :duplicate-file {:file-id id :name new-name})
(rx/tap on-success)
(rx/map file-created)
(rx/catch on-error))))))
(str " " copy-count))))]
(rx/concat
(->> (rx/from files)
(rx/merge-map
(fn [{:keys [id name]}]
(let [new-name (cfh/generate-unique-name name unames :suffix-fn suffix-fn)]
(->> (rp/cmd! :duplicate-file {:file-id id :name new-name})
(rx/map file-created))))))
(rx/of (notify-duplicated (count files) unsaved)))))))
(defn duplicate-files
"Sends the pending changes of `files`, copies them and tells the user
once every copy exists."
[files]
(dm/assert! (every? (comp uuid? :id) files))
(dm/assert! (every? (comp string? :name) files))
(ptk/reify ::duplicate-files
ptk/WatchEvent
(watch [_ _ _]
;; The copy is made from the server's data: send the pending changes first.
(rx/concat
(dps/force-persist-files-and-wait (into #{} (map :id) files) save-wait-timeout-ms)
(rx/of (copy-files files))))))
;; --- EVENT: move-files

View File

@ -38,13 +38,13 @@
(into #{} (keep :file-id) exports))
(defn- warn-unsaved-changes
"Warns that the export may miss the changes of `file-ids` not saved yet."
[file-ids]
"Shows `message` when some changes of `file-ids` are not saved yet."
[file-ids message]
(ptk/reify ::warn-unsaved-changes
ptk/WatchEvent
(watch [_ state _]
(when (dwp/unsaved-changes? state file-ids)
(rx/of (ntf/show {:content (tr "workspace.export.unsaved-changes")
(rx/of (ntf/show {:content message
:type :toast
:level :warning
:timeout 8000}))))))
@ -362,11 +362,16 @@
ptk/WatchEvent
(watch [_ state _]
(case @target
;; Drawn from the local data, which a rejected save may still change
:client
(rx/of (request-local-export-wasm {:exports [export]
:cmd :export-shapes
:name (:name export)
:kind :single}))
(rx/concat
(dwp/force-persist-files-and-wait (export-file-ids [export]) save-wait-timeout-ms)
(rx/of (warn-unsaved-changes (export-file-ids [export])
(tr "workspace.export.unsaved-changes-included"))
(request-local-export-wasm {:exports [export]
:cmd :export-shapes
:name (:name export)
:kind :single})))
:too-large
(rx/of (ntf/error (tr "errors.export-too-large")))
@ -379,7 +384,8 @@
:is-wasm (wasm-export-enabled? state)})]
(rx/concat
(dwp/force-persist-files-and-wait (export-file-ids [export]) save-wait-timeout-ms)
(rx/of (warn-unsaved-changes (export-file-ids [export])))
(rx/of (warn-unsaved-changes (export-file-ids [export])
(tr "workspace.export.unsaved-changes")))
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [filename mtype uri]}]
@ -555,7 +561,7 @@
(rx/concat
(dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms)
(rx/of (warn-unsaved-changes file-ids))
(rx/of (warn-unsaved-changes file-ids (tr "workspace.export.unsaved-changes")))
(rx/merge
;; Updates the export state with each progress message, until
;; shortly after the export ends or fails.
@ -599,7 +605,12 @@
ptk/WatchEvent
(watch [_ state _]
(if (wasm-export-enabled? state)
(rx/of (request-local-export-wasm params))
;; Drawn from the local data, which a rejected save may still change
(let [file-ids (export-file-ids exports)]
(rx/concat
(dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms)
(rx/of (warn-unsaved-changes file-ids (tr "workspace.export.unsaved-changes-included"))
(request-local-export-wasm params))))
(rx/of (request-multiple-export-server params)))))))
(defn request-export

View File

@ -17,6 +17,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dps]
[app.main.data.plugins :as dpl]
[app.main.data.websocket :as dws]
[app.main.data.workspace :as-alias dw]
@ -302,13 +303,27 @@
[:map {:title "handle-file-restored"}
[:type :keyword]
[:file-id ::sm/uuid]
[:vern :int]])
[:vern :int]
[:profile-id {:optional true} ::sm/uuid]
[:label {:optional true} :string]
[:created-at {:optional true} ::ct/inst]])
(def ^:private check-file-restored-params
(sm/check-fn schema:handle-file-restored))
(defn- restorer-fullname
"Name of the user who restored the file, when known: by `profile-id`, or
else by the presence of the session that restored it."
[state profile-id session-id]
(when-let [profile-id (or profile-id
(dm/get-in state [:workspace-presence session-id :profile-id]))]
(dm/get-in state [:profiles profile-id :fullname])))
(defn handle-file-restored
[{:keys [file-id vern] :as msg}]
"Reloads the file a peer restored to an earlier version and tells the
user who restored which version, and whether their edits not saved yet
were dropped with the replaced version."
[{:keys [file-id vern session-id profile-id label created-at] :as msg}]
(assert (check-file-restored-params msg)
"expected valid parameters")
@ -320,9 +335,16 @@
file (dsh/lookup-file state curr-file-id)
curr-vern (:vern file)]
(when (and (= file-id curr-file-id)
(not= vern curr-vern))
(rx/of (ptk/event ::dw/reload-current-file)))))))
(if (and (= file-id curr-file-id)
(not= vern curr-vern))
(rx/of (ptk/event ::dw/reload-current-file)
(modal/show :file-restored
{:fullname (restorer-fullname state profile-id session-id)
:label label
:created-at created-at
;; Made on the replaced version, so the reload drops them
:unsaved (boolean (seq (dps/pending-file-changes state file)))}))
(rx/empty))))))
(def ^:private schema:handle-library-change
[:map {:title "handle-library-change"}

View File

@ -1529,7 +1529,8 @@
:element-type (cfh/get-selected-type objects ids)
:moved moved-count}))))
(when (and (some? frame-id) (d/not-empty? changes))
;; Shapes that already belong to the frame leave nothing to move
(when (and (some? frame-id) (seq ids))
(rx/of (dch/commit-changes changes)
(dwc/expand-collapse frame-id)))
(when add-component-to-variant?

View File

@ -77,7 +77,13 @@
(rx/mapcat
(fn [{:keys [id]}]
(rx/of (update-versions-state {:editing id})
(fetch-versions))))))))))
(fetch-versions))))
;; A version of the file without the unsaved changes would
;; lose them, so none is made and the user is told why
(rx/catch (fn [cause]
(if (= :persistence (:type (ex-data cause)))
(rx/of (ntf/warn (tr "workspace.versions.create-failed-unsaved")))
(rx/throw cause))))))))))
(defn rename-version
[id label]

View File

@ -120,8 +120,7 @@
on-duplicate
(fn []
(apply st/emit! (map dd/duplicate-file files))
(st/emit! (ntf/success (tr "dashboard.success-duplicate-file" (i18n/c file-count)))))
(st/emit! (dd/duplicate-files files)))
on-delete-accept
(fn [_]

View File

@ -27,6 +27,7 @@
[app.main.ui.workspace.components-debugger :refer [components-debugger*]]
[app.main.ui.workspace.context-menu :refer [context-menu*]]
[app.main.ui.workspace.coordinates :as coordinates]
[app.main.ui.workspace.file-restored-modal]
[app.main.ui.workspace.libraries]
[app.main.ui.workspace.nudge]
[app.main.ui.workspace.palette :refer [palette*]]

View File

@ -0,0 +1,59 @@
;; 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.ui.workspace.file-restored-modal
(:require-macros [app.main.style :as stl])
(:require
[app.common.time :as ct]
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(defn- restored-message
"Who restored which version: the version by its `label`, else by its
date, else as an earlier version."
[fullname label created-at]
(let [date (some-> created-at (ct/format-inst "PPp"))]
(cond
(and fullname label) (tr "workspace.file-restored.message-label" fullname label)
label (tr "workspace.file-restored.message-label-anonymous" label)
(and fullname date) (tr "workspace.file-restored.message-date" fullname date)
date (tr "workspace.file-restored.message-date-anonymous" date)
fullname (tr "workspace.file-restored.message" fullname)
:else (tr "workspace.file-restored.message-anonymous"))))
#_{:clojure-lsp/ignore [:clojure-lsp/unused-public-var]}
(mf/defc file-restored-modal*
"Tells a peer that another user restored a version of the open file,
which was reloaded, and whether their edits not saved yet were lost."
{::mf/register modal/components
::mf/register-as :file-restored}
[{:keys [fullname label created-at unsaved]}]
(let [on-close (mf/use-fn #(st/emit! (modal/hide)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog)
:role "dialog"
:aria-modal true
:aria-labelledby "file-restored-title"}
[:> heading* {:id "file-restored-title"
:level 2
:typography t/title-medium
:class (stl/css :modal-title)}
(tr "workspace.file-restored.title")]
[:> text* {:as "p" :typography t/body-large}
(restored-message fullname label created-at)]
(when unsaved
[:> text* {:as "p" :typography t/body-large}
(tr "workspace.file-restored.unsaved-changes")])
[:footer {:class (stl/css :modal-footer)}
[:> button* {:on-click on-close
:variant "primary"}
(tr "workspace.file-restored.accept")]]]]))

View File

@ -0,0 +1,34 @@
// 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
@use "ds/_utils.scss" as *;
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-dialog {
@extend %modal-container-base;
color: var(--color-foreground-secondary);
display: grid;
gap: var(--sp-s);
max-width: px2rem(482); // FIXME: This should be a token
& > * {
margin: 0; // FIXME: This should be in reset styles
}
}
.modal-title {
color: var(--color-foreground-primary);
}
.modal-footer {
margin-block-start: var(--sp-m);
justify-self: end;
}

View File

@ -11,6 +11,7 @@
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dps]
[app.main.data.websocket :as dws]
[app.main.repo :as rp]
[app.main.router :as rt]
@ -190,3 +191,76 @@
(await (async/observe (ptk/watch (dd/initialize team-id) state stream)
:on-next #(swap! processed conj (ptk/type %))))))
(t/is (= 1 (count (filter #{::dcm/handle-organization-change-sso} @processed))))))
;; Scenario: the user duplicates a file whose workspace still has a change
;; waiting to be saved. Proves: the copy is requested once the save is done,
;; and only then is the user told, with a warning that the copy misses what
;; could not be saved, never replaced by the success toast.
(t/deftest ^:async duplicating-a-file-waits-for-its-pending-changes
(let [file-id (uuid/next)
commit-id (uuid/next)
calls (atom [])
answer (rx/subject)
store (ptk/store {:state {:files {}
:persistence {:queue (conj #queue [] commit-id)
:index {commit-id {:id commit-id :file-id file-id}}}}})]
(await
(mock/with-mocks*
{dps/force-persist-files-and-wait (mock/stub (fn [file-ids _]
(swap! calls conj [:wait file-ids])
(rx/empty)))
rp/cmd! (mock/stub (fn [cmd _]
(swap! calls conj [cmd])
(->> answer (rx/take 1) (rx/observe-on :async))))
i18n/tr (fn ([key] (str "translated:" key)) ([key & _] (str "translated:" key)))}
(ptk/emit! store (dd/duplicate-files [{:id file-id :name "File"}]))
(await (async/wait-for #(= 2 (count @calls)) "the copy is requested"))
(t/is (= [[:wait #{file-id}] [:duplicate-file]] @calls))
(t/is (nil? (:notification @store)) "nothing is told before the copy exists")
(rx/push! answer {:id (uuid/next)})
(await (async/wait-for #(some? (:notification @store)) "the user is told"))
(await (async/settle))
(t/is (= "translated:dashboard.duplicate-unsaved-changes"
(get-in @store [:notification :content])))
(t/is (= :warning (get-in @store [:notification :level])))))))
;; Scenario: the user duplicates a file with everything saved. Proves: the
;; success toast shows once the copy exists, not when it is requested.
(t/deftest ^:async duplicating-a-saved-file-tells-once-the-copy-exists
(let [file-id (uuid/next)
answer (rx/subject)
sent (atom 0)
store (ptk/store {:state {:files {}}})]
(await
(mock/with-mocks*
{dps/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty)))
rp/cmd! (mock/stub (fn [_ _]
(swap! sent inc)
(->> answer (rx/take 1) (rx/observe-on :async))))
i18n/tr (fn ([key] (str "translated:" key)) ([key & _] (str "translated:" key)))}
(ptk/emit! store (dd/duplicate-files [{:id file-id :name "File"}]))
(await (async/wait-for #(= 1 @sent) "the copy is requested"))
(t/is (nil? (:notification @store)))
(rx/push! answer {:id (uuid/next)})
(await (async/wait-for #(some? (:notification @store)) "the user is told"))
(t/is (= "translated:dashboard.success-duplicate-file"
(get-in @store [:notification :content])))
(t/is (= :success (get-in @store [:notification :level])))))))
;; Scenario: the server fails to copy the file. Proves: no success toast
;; claims a copy that does not exist.
(t/deftest ^:async a-failed-copy-tells-no-success
(let [errors (atom [])
store (ptk/store {:state {:files {}}
:on-error #(swap! errors conj %)})]
(await
(mock/with-mocks*
{dps/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty)))
rp/cmd! (mock/stub (fn [_ _]
(->> (rx/throw (ex-info "copy failed" {:type :internal}))
(rx/observe-on :async))))
i18n/tr (fn ([key] (str "translated:" key)) ([key & _] (str "translated:" key)))}
(ptk/emit! store (dd/duplicate-files [{:id (uuid/next) :name "File"}]))
(await (async/wait-for #(seq @errors) "the copy fails"))
(await (async/settle))
(t/is (nil? (:notification @store)))))))

View File

@ -454,3 +454,49 @@
selected (de/collect-export-objects state exports)]
(t/is (= #{root child} (set (keys selected))))
(t/is (not (contains? selected other)))))
;; Scenario: a shape is drawn in the browser while its file has a change not
;; saved yet. Proves: the export waits for the save first, and the user is
;; told the export includes changes the server does not have.
(t/deftest ^:async client-export-waits-for-the-save-and-warns-of-unsaved-changes
(let [file-id (uuid/next)
calls (atom [])
store (export-store (assoc (unsaved-state file-id) :features #{"render-wasm/v1"})
(atom []))]
(await
(mock/with-mocks*
{dwp/force-persist-files-and-wait (mock/stub (fn [file-ids _]
(swap! calls conj [:wait file-ids])
(rx/empty)))
wasm.api/export-target (mock/stub (constantly :client))
de/collect-export-objects (mock/stub (fn [_ _] {}))
mw/ask-many! (mock/stub (fn [_]
(swap! calls conj [:export])
(rx/empty)))
i18n/tr (mock/stub #(str "translated:" %))
st/ongoing-tasks (atom #{})}
(ptk/emit! store (de/request-simple-export {:export (assoc export :file-id file-id)}))
(await (async/wait-for #(= 2 (count @calls)) "the export runs"))
(t/is (= [[:wait #{file-id}] [:export]] @calls)
"the export starts once the save is done")
(t/is (= "translated:workspace.export.unsaved-changes-included"
(get-in @store [:notification :content])))))))
;; Scenario: a shape is drawn in the browser with every change saved.
;; Proves: no warning is shown.
(t/deftest ^:async client-export-shows-no-warning-when-the-file-is-saved
(let [exported (atom false)
store (export-store (wasm-state) (atom []))]
(await
(mock/with-mocks*
{dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty)))
wasm.api/export-target (mock/stub (constantly :client))
de/collect-export-objects (mock/stub (fn [_ _] {}))
mw/ask-many! (mock/stub (fn [_]
(reset! exported true)
(rx/empty)))
st/ongoing-tasks (atom #{})}
(ptk/emit! store (de/request-simple-export {:export (assoc export :file-id (uuid/next))}))
(await (async/wait-for #(true? @exported) "the export runs"))
(await (async/settle))
(t/is (nil? (:notification @store)))))))

View File

@ -7,9 +7,12 @@
(ns frontend-tests.data.workspace-notifications-test
(:require
[app.common.data :as d]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.notifications :as dwntf]
[app.util.i18n :as i18n]
[beicon.v2.core :as rx]
@ -50,3 +53,93 @@
(t/deftest ^:async ordinary-file-change-does-not-notify
(let [events (await (watch-file-change (file-change-msg {})))]
(t/is (= [::dch/commit] (mapv ptk/type events)))))
(defn- restored-events
"Events the watch of `handle-file-restored` emits for `msg` on `state`."
[state msg]
(let [events (atom [])]
(-> (async/observe (ptk/watch (dwntf/handle-file-restored msg) state nil)
:on-next #(swap! events conj %))
(.then (fn [_] @events)))))
(defn- shown-modal
[events]
(some #(when (= ::modal/show-modal (ptk/type %))
(::modal/modal (ptk/update % {})))
events))
;; Scenario: a peer restores an earlier version of the open file. The file
;; reloads and a modal names the peer. Proves: the reload is not silent.
(t/deftest ^:async a-peer-restore-reloads-and-names-the-peer
(let [file-id (uuid/next)
session-id (uuid/next)
profile-id (uuid/next)
state {:current-file-id file-id
:files {file-id {:id file-id :vern 1}}
:workspace-presence {session-id {:profile-id profile-id}}
:profiles {profile-id {:id profile-id :fullname "Ada"}}}
events (await (restored-events state {:type :file-restored
:file-id file-id
:session-id session-id
:vern 2}))
modal (shown-modal events)]
(t/is (some #(= ::dw/reload-current-file (ptk/type %)) events))
(t/is (= :file-restored (:type modal)))
(t/is (= "Ada" (get-in modal [:props :fullname])))
(t/is (false? (get-in modal [:props :unsaved])))))
;; Scenario: a peer restores a version they named, while this tab has a
;; rename not saved yet and the restorer has no presence. Proves: the modal
;; names the restorer by profile and the version by its label, and says the
;; unsaved rename is lost.
(t/deftest ^:async a-peer-restore-names-the-version-and-the-lost-edits
(let [file-id (uuid/next)
profile-id (uuid/next)
created-at (ct/now)
commit-id (uuid/next)
state {:current-file-id file-id
:files {file-id {:id file-id :vern 1}}
:profiles {profile-id {:id profile-id :fullname "Ada"}}
:persistence {:queue [commit-id]
:index {commit-id {:id commit-id
:file-id file-id
:file-vern 1
:commits [{:redo-changes [{:type :mod-page}]}]}}}}
events (await (restored-events state {:type :file-restored
:file-id file-id
:session-id (uuid/next)
:profile-id profile-id
:label "Hand-off v2"
:created-at created-at
:vern 2}))
props (:props (shown-modal events))]
(t/is (= "Ada" (:fullname props)))
(t/is (= "Hand-off v2" (:label props)))
(t/is (= created-at (:created-at props)))
(t/is (true? (:unsaved props)))))
;; Scenario: the restoring session left before its message arrived, so it
;; has no presence. Proves: the modal still shows, without a name.
(t/deftest ^:async a-restore-from-an-unknown-session-shows-no-name
(let [file-id (uuid/next)
state {:current-file-id file-id
:files {file-id {:id file-id :vern 1}}}
events (await (restored-events state {:type :file-restored
:file-id file-id
:session-id (uuid/next)
:vern 2}))
modal (shown-modal events)]
(t/is (= :file-restored (:type modal)))
(t/is (nil? (get-in modal [:props :fullname])))))
;; Scenario: the file already shows the restored version. Proves: no reload
;; and no modal for a version the user already sees.
(t/deftest ^:async a-restore-of-the-loaded-version-does-nothing
(let [file-id (uuid/next)
state {:current-file-id file-id
:files {file-id {:id file-id :vern 2}}}
events (await (restored-events state {:type :file-restored
:file-id file-id
:session-id (uuid/next)
:vern 2}))]
(t/is (empty? events))))

View File

@ -8,6 +8,7 @@
(:require
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dps]
[app.main.data.workspace :as dw]
[app.main.data.workspace.pages :as dwpg]
@ -27,6 +28,7 @@
(let [file-id (uuid/next)
resolved (atom [])
rejected (atom [])
emitted (atom [])
requests (atom [])
state {:current-file-id file-id}
event (case operation
@ -42,12 +44,22 @@
rx/timeout (mock/stub (fn [_ fallback source]
(if (= failure :timeout) fallback source)))]
(->> (ptk/watch event state (rx/empty))
(rx/subs! (fn [_]) #(swap! rejected conj %)))
(rx/subs! #(swap! emitted conj %) #(swap! rejected conj %)))
(t/is (empty? @requests) "Do not create a version without saving")
(t/is (empty? @resolved) "Do not resolve the plugin promise on failure")
(t/is (= 1 (count @rejected)))
(t/is (= (if (= failure :error) :save-permission-denied :save-timeout)
(:code (ex-data (first @rejected))))))))))
(if (= operation :create)
;; The save failure is reported on its own; the user is told
;; why the version is missing instead
(let [notice (some #(when (= ::ntf/show (ptk/type %))
(:notification (ptk/update % {})))
@emitted)]
(t/is (empty? @rejected))
(t/is (= "workspace.versions.create-failed-unsaved" (:content notice)))
(t/is (= :warning (:level notice))))
(do
(t/is (= 1 (count @rejected)))
(t/is (= (if (= failure :error) :save-permission-denied :save-timeout)
(:code (ex-data (first @rejected))))))))))))
(t/deftest leaving-the-workspace-clears-the-preview-flags
(let [team-id (uuid/next)

View File

@ -0,0 +1,65 @@
;; 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 frontend-tests.logic.move-shapes-to-frame-test
"Tests for the reparenting step that ends a move (`dwt/move-shapes-to-frame`)."
(:require
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.workspace.transforms :as dwt]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]
[potok.v2.core :as ptk]))
(t/use-fixtures :each {:before cthi/reset-idmap!})
(defn- count-commits
"Runs `event` on a store of `file` and calls `check` with the number of
commits it made and the resulting file."
[done file event check]
(let [store (ths/setup-store file)
commits (atom 0)
sub (->> (ptk/input-stream store)
(rx/filter dch/commit?)
(rx/subs! (fn [_] (swap! commits inc))))]
(ths/run-store store done [event]
(fn [state]
(rx/dispose! sub)
(check @commits (ths/get-file-from-state state))))))
;; Scenario: a move ends over the frame the shape already belongs to.
;; Proves: nothing changes parent, so no commit (and no save) is made.
(t/deftest moving-a-shape-within-its-frame-commits-nothing
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-rect :rect1))
rect (cths/get-shape file :rect1)]
(count-commits done file
(dwt/move-shapes-to-frame [(:id rect)] uuid/zero nil nil)
(fn [commits _]
(t/is (zero? commits)))))))
;; Scenario: a move ends over another board. Proves: the shape is moved into
;; it with one commit.
(t/deftest moving-a-shape-into-a-board-commits-the-new-parent
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-frame :frame1)
(ctho/add-rect :rect1))
rect (cths/get-shape file :rect1)
frame (cths/get-shape file :frame1)]
(count-commits done file
(dwt/move-shapes-to-frame [(:id rect)] (:id frame) nil nil)
(fn [commits file']
(t/is (= 1 commits))
(t/is (= (:id frame) (:parent-id (cths/get-shape file' :rect1)))))))))

View File

@ -37,6 +37,7 @@
[frontend-tests.data.workspace-libraries-sync-test]
[frontend-tests.data.workspace-mcp-test]
[frontend-tests.data.workspace-media-test]
[frontend-tests.data.workspace-notifications-test]
[frontend-tests.data.workspace-pages-test]
[frontend-tests.data.workspace-path-edition-test]
[frontend-tests.data.workspace-reflow-test]
@ -61,6 +62,7 @@
[frontend-tests.logic.frame-guides-test]
[frontend-tests.logic.groups-test]
[frontend-tests.logic.ignore-tree-test]
[frontend-tests.logic.move-shapes-to-frame-test]
[frontend-tests.logic.nudge-selected-shapes-test]
[frontend-tests.logic.pasting-in-containers-test]
[frontend-tests.logic.path-actions-test]
@ -137,6 +139,7 @@
[frontend-tests.ui.drawarea-test]
[frontend-tests.ui.ds-controls-numeric-input-test]
[frontend-tests.ui.error-boundary-test]
[frontend-tests.ui.file-restored-modal-test]
[frontend-tests.ui.gradient-handlers-test]
[frontend-tests.ui.history-test]
[frontend-tests.ui.inspect-geometry-test]
@ -223,6 +226,7 @@
'frontend-tests.data.workspace-libraries-sync-test
'frontend-tests.data.workspace-mcp-test
'frontend-tests.data.workspace-media-test
'frontend-tests.data.workspace-notifications-test
'frontend-tests.data.workspace-pages-test
'frontend-tests.data.workspace-path-edition-test
'frontend-tests.data.workspace-reflow-test
@ -247,6 +251,7 @@
'frontend-tests.logic.frame-guides-test
'frontend-tests.logic.groups-test
'frontend-tests.logic.ignore-tree-test
'frontend-tests.logic.move-shapes-to-frame-test
'frontend-tests.logic.nudge-selected-shapes-test
'frontend-tests.logic.path-actions-test
'frontend-tests.logic.path-clipboard-test
@ -322,6 +327,7 @@
'frontend-tests.ui.drawarea-test
'frontend-tests.ui.ds-controls-numeric-input-test
'frontend-tests.ui.error-boundary-test
'frontend-tests.ui.file-restored-modal-test
'frontend-tests.ui.gradient-handlers-test
'frontend-tests.ui.history-test
'frontend-tests.ui.inspect-geometry-test

View File

@ -0,0 +1,34 @@
;; 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 frontend-tests.ui.file-restored-modal-test
(:require
[app.common.time :as ct]
[app.main.ui.workspace.file-restored-modal :as frm]
[app.util.i18n :as i18n]
[cljs.test :as t :include-macros true]))
(defn- message
"The translation key and arguments of the restore message."
[fullname label created-at]
(with-redefs [i18n/tr (fn ([key] [key]) ([key & args] (into [key] args)))]
(#'frm/restored-message fullname label created-at)))
(t/deftest the-message-names-the-version-by-its-label-or-its-date
(let [created-at (ct/now)
date (ct/format-inst created-at "PPp")]
(t/is (= ["workspace.file-restored.message-label" "Ada" "Hand-off v2"]
(message "Ada" "Hand-off v2" created-at)))
(t/is (= ["workspace.file-restored.message-label-anonymous" "Hand-off v2"]
(message nil "Hand-off v2" created-at)))
(t/is (= ["workspace.file-restored.message-date" "Ada" date]
(message "Ada" nil created-at)))
(t/is (= ["workspace.file-restored.message-date-anonymous" date]
(message nil nil created-at)))
(t/is (= ["workspace.file-restored.message" "Ada"]
(message "Ada" nil nil)))
(t/is (= ["workspace.file-restored.message-anonymous"]
(message nil nil nil)))))

View File

@ -441,6 +441,12 @@ msgstr "Duplicate"
msgid "dashboard.duplicate-multi"
msgstr "Duplicate %s files"
#: src/app/main/data/dashboard.cljs
msgid "dashboard.duplicate-unsaved-changes"
msgid_plural "dashboard.duplicate-unsaved-changes"
msgstr[0] "Your file has been duplicated, but the copy doesn't include your latest changes because they aren't saved yet."
msgstr[1] "Your files have been duplicated, but the copies don't include your latest changes because they aren't saved yet."
#: src/app/main/ui/dashboard/files.cljs:233, src/app/main/ui/dashboard/projects.cljs:309
msgid "dashboard.empty-placeholder-drafts-subtitle"
msgstr "Once a project member creates a draft, it will be displayed here."
@ -6631,6 +6637,46 @@ msgstr "Debugging tools"
msgid "workspace.export.unsaved-changes"
msgstr "Some of your latest changes are not saved yet, so this export may not include them."
#: src/app/main/data/exports/assets.cljs
msgid "workspace.export.unsaved-changes-included"
msgstr "Some of your latest changes are not saved yet. This export includes them."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.accept"
msgstr "Got it"
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message"
msgstr "%s restored an earlier version of this file. Changes made after it are no longer in the file."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-anonymous"
msgstr "A teammate restored an earlier version of this file. Changes made after it are no longer in the file."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-date"
msgstr "%s restored the version from %s. Changes made after it are no longer in the file."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-date-anonymous"
msgstr "A teammate restored the version from %s. Changes made after it are no longer in the file."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-label"
msgstr "%s restored the version “%s”. Changes made after it are no longer in the file."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-label-anonymous"
msgstr "A teammate restored the version “%s”. Changes made after it are no longer in the file."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.title"
msgstr "This file was restored"
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.unsaved-changes"
msgstr "Your latest changes couldn't be saved because a version of the file was restored."
#: src/app/main/ui/workspace/sidebar/layers.cljs:907
msgid "workspace.focus.focus-mode"
msgstr "Focus mode"
@ -9787,6 +9833,10 @@ msgstr "Restore version"
msgid "workspace.versions.button.save"
msgstr "Save version"
#: src/app/main/data/workspace/versions.cljs
msgid "workspace.versions.create-failed-unsaved"
msgstr "The version was not created because your changes could not be saved."
#: src/app/main/ui/workspace/sidebar/versions.cljs:434
msgid "workspace.versions.empty"
msgstr "There are no versions yet"

View File

@ -449,6 +449,16 @@ msgstr "Duplicar"
msgid "dashboard.duplicate-multi"
msgstr "Duplicar %s archivos"
#: src/app/main/data/dashboard.cljs
msgid "dashboard.duplicate-unsaved-changes"
msgid_plural "dashboard.duplicate-unsaved-changes"
msgstr[0] ""
"Se ha duplicado tu archivo, pero la copia no incluye tus últimos cambios "
"porque aún no se han guardado."
msgstr[1] ""
"Se han duplicado tus archivos, pero las copias no incluyen tus últimos "
"cambios porque aún no se han guardado."
#: src/app/main/ui/dashboard/files.cljs:233, src/app/main/ui/dashboard/projects.cljs:309
msgid "dashboard.empty-placeholder-drafts-subtitle"
msgstr "Cuando un miembro del equipo cree algún borrador, este aparecerá aquí."
@ -2279,6 +2289,12 @@ msgid_plural "files-export-modal.exporting.title"
msgstr[0] "Exportando 1 archivo"
msgstr[1] "Exportando %s archivos"
#: src/app/main/ui/exports/files.cljs
msgid "files-download-modal.unsaved-changes"
msgstr ""
"Algunos de tus últimos cambios aún no se han guardado, así que esta "
"descarga no los incluirá."
#: src/app/main/ui/exports/files.cljs:183
msgid "files-export-modal.options.detach-libraries.message"
msgstr ""
@ -2317,10 +2333,6 @@ msgstr ""
msgid "files-export-modal.options.merge-libraries.title"
msgstr "Incluir librerias compartidas dentro de las librerias del fichero"
#: src/app/main/ui/exports/files.cljs
msgid "files-download-modal.unsaved-changes"
msgstr "Algunos de tus últimos cambios aún no se han guardado, así que esta descarga no los incluirá."
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.status.exported"
msgstr "Exportado"
@ -2363,7 +2375,9 @@ msgstr "No se encontraron diferencias entre los atajos actuales y los importados
#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153
msgid "import-shortcuts.skipped-entries"
msgstr "Estas entradas no se pueden importar: no corresponden a ningún atajo de Penpot o usan una combinación reservada."
msgstr ""
"Estas entradas no se pueden importar: no corresponden a ningún atajo de "
"Penpot o usan una combinación reservada."
#: src/app/main/ui/components/color_input.cljs:31
msgid "inspect.attributes.color"
@ -6157,9 +6171,6 @@ msgstr "Suscríbete"
msgid "subscription.settings.subscribed-since"
msgstr "Llevas en el plan %s desde %s."
msgid "subscription.settings.trial-ends-on"
msgstr "Tu prueba gratuita acaba el %s."
#: src/app/main/ui/settings/subscription.cljs:399
msgid "subscription.settings.success.dialog.description"
msgstr ""
@ -6178,6 +6189,9 @@ msgstr "¡Gracias por elegir el plan %s de Penpot!"
msgid "subscription.settings.success.dialog.title"
msgstr "Eres %s!"
msgid "subscription.settings.trial-ends-on"
msgstr "Tu prueba gratuita acaba el %s."
#: src/app/main/ui/settings/subscription.cljs:749, src/app/main/ui/settings/subscription.cljs:765
msgid "subscription.settings.try-it-free"
msgstr "Pruébalo gratis durante 14 días"
@ -6734,7 +6748,65 @@ msgstr "Herramientas de depuración"
#: src/app/main/data/exports/assets.cljs
msgid "workspace.export.unsaved-changes"
msgstr "Algunos de tus últimos cambios aún no se han guardado, así que puede que esta exportación no los incluya."
msgstr ""
"Algunos de tus últimos cambios aún no se han guardado, así que puede que "
"esta exportación no los incluya."
#: src/app/main/data/exports/assets.cljs
msgid "workspace.export.unsaved-changes-included"
msgstr ""
"Algunos de tus últimos cambios aún no se han guardado. Esta exportación los "
"incluye."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.accept"
msgstr "Entendido"
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message"
msgstr ""
"%s ha restaurado una versión anterior de este archivo. Los cambios "
"posteriores ya no están en el archivo."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-anonymous"
msgstr ""
"Un miembro del equipo ha restaurado una versión anterior de este archivo. "
"Los cambios posteriores ya no están en el archivo."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-date"
msgstr ""
"%s ha restaurado la versión del %s. Los cambios posteriores ya no están en "
"el archivo."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-date-anonymous"
msgstr ""
"Un miembro del equipo ha restaurado la versión del %s. Los cambios "
"posteriores ya no están en el archivo."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-label"
msgstr ""
"%s ha restaurado la versión «%s». Los cambios posteriores ya no están en el "
"archivo."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.message-label-anonymous"
msgstr ""
"Un miembro del equipo ha restaurado la versión «%s». Los cambios "
"posteriores ya no están en el archivo."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.title"
msgstr "Se ha restaurado este archivo"
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.unsaved-changes"
msgstr ""
"No se han podido guardar tus últimos cambios porque se ha restaurado una "
"versión del archivo."
#: src/app/main/ui/workspace/sidebar/layers.cljs:907
msgid "workspace.focus.focus-mode"
@ -9914,6 +9986,10 @@ msgstr "Restaurar versión"
msgid "workspace.versions.button.save"
msgstr "Guardar versión"
#: src/app/main/data/workspace/versions.cljs
msgid "workspace.versions.create-failed-unsaved"
msgstr "No se ha creado la versión porque tus cambios no se han podido guardar."
#: src/app/main/ui/workspace/sidebar/versions.cljs:434
msgid "workspace.versions.empty"
msgstr "No hay versiones aún"