🐛 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 it.
- 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 46209cef13
commit b0fa06741e
15 changed files with 366 additions and 15 deletions

View File

@ -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 `duplicate-file` 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.
- 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

@ -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]
@ -529,6 +530,21 @@
;; --- EVENT: 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- warn-unsaved-copy
"Warns that the copy of `file-id` misses the changes not saved yet."
[file-id]
(ptk/reify ::warn-unsaved-copy
ptk/WatchEvent
(watch [_ state _]
(when (dps/unsaved-changes? state #{file-id})
(rx/of (ntf/show {:content (tr "dashboard.duplicate-unsaved-changes")
:type :toast
:level :warning
:timeout 8000}))))))
(defn duplicate-file
[{:keys [id name] :as params}]
(dm/assert! (uuid? id))
@ -546,10 +562,14 @@
(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))))))
;; The copy is made from the server's data: send the pending changes first.
(rx/concat
(dps/force-persist-files-and-wait #{id} save-wait-timeout-ms)
(rx/of (warn-unsaved-copy id))
(->> (rp/cmd! :duplicate-file {:file-id id :name new-name})
(rx/tap on-success)
(rx/map file-created)
(rx/catch on-error)))))))
;; --- EVENT: move-files

View File

@ -36,13 +36,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}))))))
@ -339,8 +339,13 @@
ptk/WatchEvent
(watch [_ state _]
(case @target
;; Drawn from the local data, which a rejected save may still change
:client
(rx/of (request-simple-export-wasm export))
(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-simple-export-wasm export)))
:too-large
(rx/of (ntf/error (tr "errors.export-too-large")))
@ -353,7 +358,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]}]
@ -407,7 +413,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.

View File

@ -307,8 +307,16 @@
(def ^:private check-file-restored-params
(sm/check-fn schema:handle-file-restored))
(defn- restorer-fullname
"Name of the user whose session restored the file, when known."
[state session-id]
(when-let [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 why it changed."
[{:keys [file-id vern session-id] :as msg}]
(assert (check-file-restored-params msg)
"expected valid parameters")
@ -320,9 +328,12 @@
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 session-id)}))
(rx/empty))))))
(def ^:private schema:handle-library-change
[:map {:title "handle-library-change"}

View File

@ -1511,7 +1511,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

@ -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,44 @@
;; 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.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]))
#_{:clojure-lsp/ignore [:clojure-lsp/unused-public-var]}
(mf/defc file-restored-modal*
"Tells a peer that another user restored an earlier version of the open
file, which was reloaded."
{::mf/register modal/components
::mf/register-as :file-restored}
[{:keys [fullname]}]
(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}
(if fullname
(tr "workspace.file-restored.message" fullname)
(tr "workspace.file-restored.message-anonymous"))]
[:footer {:class (stl/css :modal-footer)}
[:> button* {:on-click on-close
:variant "primary"}
(tr "ds.confirm-ok")]]]]))

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]
@ -172,3 +173,28 @@
(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 the user is told the copy misses what could not be saved.
(t/deftest ^:async duplicating-a-file-waits-for-its-pending-changes
(let [file-id (uuid/next)
commit-id (uuid/next)
calls (atom [])
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])
(rx/of {:id (uuid/next)})))
i18n/tr (mock/stub #(str "translated:" %))}
(ptk/emit! store (dd/duplicate-file {: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 (= "translated:dashboard.duplicate-unsaved-changes"
(get-in @store [:notification :content])))))))

View File

@ -8,6 +8,7 @@
(:require
[app.common.uuid :as uuid]
[app.main.data.exports.assets :as de]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.persistence :as dwp]
[app.main.errors :as errors]
[app.main.repo :as repo]
@ -203,3 +204,39 @@
(await (async/wait-for #(true? @downloaded) "the export runs"))
(await (async/settle))
(t/is (nil? (:notification @store)))))))
;; Scenario: a PDF 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.exports/export-pdf (mock/stub (fn [_] (swap! calls conj [:export])))
i18n/tr (mock/stub #(str "translated:" %))}
(ptk/emit! store (de/request-simple-export {:export (assoc export :file-id file-id :type :pdf)}))
(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 PDF 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 (assoc (test-state) :features #{"render-wasm/v1"}) (atom []))]
(await
(mock/with-mocks*
{dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty)))
wasm.exports/export-pdf (mock/stub (fn [_] (reset! exported true)))}
(ptk/emit! store (de/request-simple-export {:export (assoc export :file-id (uuid/next) :type :pdf)}))
(await (async/wait-for #(true? @exported) "the export runs"))
(await (async/settle))
(t/is (nil? (:notification @store)))))))

View File

@ -9,7 +9,9 @@
[app.common.data :as d]
[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 +52,62 @@
(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])))))
;; 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

@ -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

@ -34,6 +34,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]
@ -55,6 +56,7 @@
[frontend-tests.logic.copying-and-duplicating-test]
[frontend-tests.logic.frame-guides-test]
[frontend-tests.logic.groups-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]
@ -190,6 +192,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
@ -211,6 +214,7 @@
'frontend-tests.logic.copying-and-duplicating-test
'frontend-tests.logic.frame-guides-test
'frontend-tests.logic.groups-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

View File

@ -437,6 +437,10 @@ msgstr "Duplicate"
msgid "dashboard.duplicate-multi"
msgstr "Duplicate %s files"
#: src/app/main/data/dashboard.cljs
msgid "dashboard.duplicate-unsaved-changes"
msgstr "Some of your latest changes are not saved yet, so the copy will not include them."
#: 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."
@ -6450,6 +6454,22 @@ 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.message"
msgstr "%s restored an earlier version of this file. You are now seeing that version."
#: 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. You are now seeing that version."
#: src/app/main/ui/workspace/file_restored_modal.cljs
msgid "workspace.file-restored.title"
msgstr "This file was restored"
#: src/app/main/ui/workspace/sidebar/layers.cljs:907
msgid "workspace.focus.focus-mode"
msgstr "Focus mode"

View File

@ -445,6 +445,10 @@ msgstr "Duplicar"
msgid "dashboard.duplicate-multi"
msgstr "Duplicar %s archivos"
#: src/app/main/data/dashboard.cljs
msgid "dashboard.duplicate-unsaved-changes"
msgstr "Algunos de tus últimos cambios aún no se han guardado, así que la copia no los incluirá."
#: 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í."
@ -6516,6 +6520,22 @@ msgstr "Herramientas de depuración"
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."
#: 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.message"
msgstr "%s ha restaurado una versión anterior de este archivo. Ahora ves esa versión."
#: 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. Ahora ves esa versión."
#: 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/sidebar/layers.cljs:907
msgid "workspace.focus.focus-mode"
msgstr "Modo foco"