mirror of
https://github.com/penpot/penpot.git
synced 2025-01-06 14:50:20 -05:00
✨ File history versions management
This commit is contained in:
parent
fa4f2aa5cc
commit
ecb7f0a2f6
33 changed files with 1100 additions and 102 deletions
|
@ -13,6 +13,7 @@
|
|||
### :sparkles: New features
|
||||
|
||||
- Viewer role for team members [Taiga #1056 & #6590](https://tree.taiga.io/project/penpot/us/1056 & https://tree.taiga.io/project/penpot/us/6590)
|
||||
- File history versions management [Taiga](https://tree.taiga.io/project/penpot/us/187?milestone=411120)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
|
|
|
@ -89,6 +89,10 @@
|
|||
(dissoc ::s/problems ::s/value ::s/spec ::sm/explain)
|
||||
(cond-> explain (assoc :explain explain)))})
|
||||
|
||||
(= code :vern-conflict)
|
||||
{::yres/status 409 ;; 409 - Conflict
|
||||
::yres/body data}
|
||||
|
||||
(= code :request-body-too-large)
|
||||
{::yres/status 413 ::yres/body data}
|
||||
|
||||
|
|
|
@ -415,7 +415,13 @@
|
|||
:fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")}
|
||||
|
||||
{:name "0131-mod-webhook-table"
|
||||
:fn (mg/resource "app/migrations/sql/0131-mod-webhook-table.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0131-mod-webhook-table.sql")}
|
||||
|
||||
{:name "0132-mod-file-change-table"
|
||||
:fn (mg/resource "app/migrations/sql/0132-mod-file-change-table.sql")}
|
||||
|
||||
{:name "0133-mod-file-table"
|
||||
:fn (mg/resource "app/migrations/sql/0133-mod-file-table.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE file_change
|
||||
ADD COLUMN created_by text NOT NULL DEFAULT 'system';
|
2
backend/src/app/migrations/sql/0133-mod-file-table.sql
Normal file
2
backend/src/app/migrations/sql/0133-mod-file-table.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE file
|
||||
ADD COLUMN vern int NOT NULL DEFAULT 0;
|
|
@ -182,6 +182,7 @@
|
|||
[:comment-thread-seqn [::sm/int {:min 0}]]
|
||||
[:name [:string {:max 250}]]
|
||||
[:revn [::sm/int {:min 0}]]
|
||||
[:vern [::sm/int {:min 0}]]
|
||||
[:modified-at ::dt/instant]
|
||||
[:is-shared ::sm/boolean]
|
||||
[:project-id ::sm/uuid]
|
||||
|
@ -270,7 +271,7 @@
|
|||
|
||||
(defn get-minimal-file
|
||||
[cfg id & {:as opts}]
|
||||
(let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :data-ref-id :data-backend])]
|
||||
(let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :vern :data-ref-id :data-backend])]
|
||||
(db/get cfg :file {:id id} opts)))
|
||||
|
||||
(defn- get-minimal-file-with-perms
|
||||
|
@ -280,8 +281,8 @@
|
|||
(assoc mfile :permissions perms)))
|
||||
|
||||
(defn get-file-etag
|
||||
[{:keys [::rpc/profile-id]} {:keys [modified-at revn permissions]}]
|
||||
(str profile-id "/" revn "/"
|
||||
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}]
|
||||
(str profile-id "/" revn "/" vern "/"
|
||||
(dt/format-instant modified-at :iso)
|
||||
"/"
|
||||
(uri/map->query-string permissions)))
|
||||
|
@ -371,6 +372,7 @@
|
|||
f.modified_at,
|
||||
f.name,
|
||||
f.revn,
|
||||
f.vern,
|
||||
f.is_shared,
|
||||
ft.media_id
|
||||
from file as f
|
||||
|
@ -526,6 +528,7 @@
|
|||
(def ^:private sql:team-shared-files
|
||||
"select f.id,
|
||||
f.revn,
|
||||
f.vern,
|
||||
f.data,
|
||||
f.project_id,
|
||||
f.created_at,
|
||||
|
@ -609,6 +612,7 @@
|
|||
l.deleted_at,
|
||||
l.name,
|
||||
l.revn,
|
||||
l.vern,
|
||||
l.synced_at
|
||||
FROM libs AS l
|
||||
WHERE l.deleted_at IS NULL OR l.deleted_at > now();")
|
||||
|
@ -670,6 +674,7 @@
|
|||
"with recent_files as (
|
||||
select f.id,
|
||||
f.revn,
|
||||
f.vern,
|
||||
f.project_id,
|
||||
f.created_at,
|
||||
f.modified_at,
|
||||
|
|
|
@ -10,14 +10,13 @@
|
|||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as-alias sql]
|
||||
[app.features.fdata :as feat.fdata]
|
||||
[app.main :as-alias main]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.storage :as sto]
|
||||
[app.util.blob :as blob]
|
||||
|
@ -26,22 +25,12 @@
|
|||
[app.util.time :as dt]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn check-authorized!
|
||||
[{:keys [::db/pool]} profile-id]
|
||||
(when-not (or (= "devenv" (cf/get :host))
|
||||
(let [profile (ex/ignoring (profile/get-profile pool profile-id))
|
||||
admins (or (cf/get :admins) #{})]
|
||||
(contains? admins (:email profile))))
|
||||
(ex/raise :type :authentication
|
||||
:code :authentication-required
|
||||
:hint "only admins allowed")))
|
||||
|
||||
(def sql:get-file-snapshots
|
||||
"SELECT id, label, revn, created_at
|
||||
"SELECT id, label, revn, created_at, created_by, profile_id
|
||||
FROM file_change
|
||||
WHERE file_id = ?
|
||||
AND created_at < ?
|
||||
AND label IS NOT NULL
|
||||
AND data IS NOT NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?")
|
||||
|
||||
|
@ -50,25 +39,23 @@
|
|||
:or {limit Long/MAX_VALUE}}]
|
||||
(let [start-at (or start-at (dt/now))
|
||||
limit (min limit 20)]
|
||||
(->> (db/exec! conn [sql:get-file-snapshots file-id start-at limit])
|
||||
(mapv (fn [row]
|
||||
(update row :created-at dt/format-instant :rfc1123))))))
|
||||
(db/exec! conn [sql:get-file-snapshots file-id start-at limit])))
|
||||
|
||||
(def ^:private schema:get-file-snapshots
|
||||
[:map [:file-id ::sm/uuid]])
|
||||
[:map {:title "get-file-snapshots"}
|
||||
[:file-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::get-file-snapshots
|
||||
{::doc/added "1.20"
|
||||
::doc/skip true
|
||||
::sm/params schema:get-file-snapshots}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(check-authorized! cfg profile-id)
|
||||
[cfg params]
|
||||
(db/run! cfg get-file-snapshots params))
|
||||
|
||||
(defn restore-file-snapshot!
|
||||
[{:keys [::db/conn] :as cfg} {:keys [file-id id]}]
|
||||
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [file-id id]}]
|
||||
(let [storage (sto/resolve cfg {::db/reuse-conn true})
|
||||
file (files/get-minimal-file conn file-id {::db/for-update true})
|
||||
vern (rand-int Integer/MAX_VALUE)
|
||||
snapshot (db/get* conn :file-change
|
||||
{:file-id file-id
|
||||
:id id}
|
||||
|
@ -103,6 +90,7 @@
|
|||
(db/update! conn :file
|
||||
{:data (:data snapshot)
|
||||
:revn (inc (:revn file))
|
||||
:vern vern
|
||||
:version (:version snapshot)
|
||||
:data-backend nil
|
||||
:data-ref-id nil
|
||||
|
@ -126,38 +114,28 @@
|
|||
(doseq [media-id (into #{} (keep :media-id) res)]
|
||||
(sto/touch-object! storage media-id)))
|
||||
|
||||
;; Send to the clients a notification to reload the file
|
||||
(mbus/pub! msgbus
|
||||
:topic (:id file)
|
||||
:message {:type :file-restore
|
||||
:file-id (:id file)
|
||||
:vern vern})
|
||||
{:id (:id snapshot)
|
||||
:label (:label snapshot)})))
|
||||
|
||||
(defn- resolve-snapshot-by-label
|
||||
[conn file-id label]
|
||||
(->> (db/query conn :file-change
|
||||
{:file-id file-id
|
||||
:label label}
|
||||
{::sql/order-by [[:created-at :desc]]
|
||||
::sql/columns [:file-id :id :label]})
|
||||
(first)))
|
||||
|
||||
(def ^:private
|
||||
schema:restore-file-snapshot
|
||||
[:and
|
||||
[:map
|
||||
[:file-id ::sm/uuid]
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
[:label {:optional true} :string]]
|
||||
[:id {:optional true} ::sm/uuid]]
|
||||
[::sm/contains-any #{:id :label}]])
|
||||
|
||||
(sv/defmethod ::restore-file-snapshot
|
||||
{::doc/added "1.20"
|
||||
::doc/skip true
|
||||
::sm/params schema:restore-file-snapshot}
|
||||
[cfg {:keys [::rpc/profile-id file-id id label] :as params}]
|
||||
(check-authorized! cfg profile-id)
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [params (cond-> params
|
||||
(and (not id) (string? label))
|
||||
(merge (resolve-snapshot-by-label conn file-id label)))]
|
||||
(restore-file-snapshot! cfg params)))))
|
||||
[cfg params]
|
||||
(db/tx-run! cfg restore-file-snapshot! params))
|
||||
|
||||
(defn- get-file
|
||||
[cfg file-id]
|
||||
|
@ -170,29 +148,7 @@
|
|||
(update :data feat.fdata/process-objects (partial into {}))
|
||||
(update :data blob/encode)))))
|
||||
|
||||
(defn take-file-snapshot!
|
||||
[cfg {:keys [file-id label ::rpc/profile-id]}]
|
||||
(let [file (get-file cfg file-id)
|
||||
id (uuid/next)]
|
||||
|
||||
(l/debug :hint "creating file snapshot"
|
||||
:file-id (str file-id)
|
||||
:label label)
|
||||
|
||||
(db/insert! cfg :file-change
|
||||
{:id id
|
||||
:revn (:revn file)
|
||||
:data (:data file)
|
||||
:version (:version file)
|
||||
:features (:features file)
|
||||
:profile-id profile-id
|
||||
:file-id (:id file)
|
||||
:label label}
|
||||
{::db/return-keys false})
|
||||
|
||||
{:id id :label label}))
|
||||
|
||||
(defn generate-snapshot-label
|
||||
(defn- generate-snapshot-label
|
||||
[]
|
||||
(let [ts (-> (dt/now)
|
||||
(dt/format-instant)
|
||||
|
@ -200,17 +156,92 @@
|
|||
(str/rtrim "Z"))]
|
||||
(str "snapshot-" ts)))
|
||||
|
||||
(defn take-file-snapshot!
|
||||
[cfg {:keys [file-id label ::rpc/profile-id]}]
|
||||
(let [label (or label (generate-snapshot-label))
|
||||
file (-> (get-file cfg file-id)
|
||||
(update :data
|
||||
(fn [data]
|
||||
(-> data
|
||||
(blob/decode)
|
||||
(assoc :id file-id)))))
|
||||
|
||||
snapshot-id
|
||||
(uuid/next)
|
||||
|
||||
snapshot-data
|
||||
(-> (:data file)
|
||||
(feat.fdata/process-pointers deref)
|
||||
(feat.fdata/process-objects (partial into {}))
|
||||
(blob/encode))]
|
||||
|
||||
(l/debug :hint "creating file snapshot"
|
||||
:file-id (str file-id)
|
||||
:id (str snapshot-id)
|
||||
:label label)
|
||||
|
||||
(db/insert! cfg :file-change
|
||||
{:id snapshot-id
|
||||
:revn (:revn file)
|
||||
:data snapshot-data
|
||||
:version (:version file)
|
||||
:features (:features file)
|
||||
:profile-id profile-id
|
||||
:file-id (:id file)
|
||||
:label label
|
||||
:created-by "user"}
|
||||
{::db/return-keys false})
|
||||
|
||||
{:id snapshot-id :label label}))
|
||||
|
||||
(def ^:private schema:take-file-snapshot
|
||||
[:map [:file-id ::sm/uuid]])
|
||||
[:map
|
||||
[:file-id ::sm/uuid]
|
||||
[:label {:optional true} :string]])
|
||||
|
||||
(sv/defmethod ::take-file-snapshot
|
||||
{::doc/added "1.20"
|
||||
::doc/skip true
|
||||
::sm/params schema:take-file-snapshot}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(check-authorized! cfg profile-id)
|
||||
(db/tx-run! cfg (fn [cfg]
|
||||
(let [params (update params :label (fn [label]
|
||||
(or label (generate-snapshot-label))))]
|
||||
(take-file-snapshot! cfg params)))))
|
||||
[cfg params]
|
||||
(db/tx-run! cfg take-file-snapshot! params))
|
||||
|
||||
(def ^:private schema:update-file-snapshot
|
||||
[:map {:title "update-file-snapshot"}
|
||||
[:id ::sm/uuid]
|
||||
[:label ::sm/text]])
|
||||
|
||||
(defn update-file-snapshot!
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id label]}]
|
||||
(let [result
|
||||
(db/update! conn :file-change
|
||||
{:label label
|
||||
:created-by "user"}
|
||||
{:id id}
|
||||
{::db/return-keys true})]
|
||||
|
||||
(select-keys result [:id :label :revn :created-at :profile-id :created-by])))
|
||||
|
||||
(sv/defmethod ::update-file-snapshot
|
||||
{::doc/added "1.20"
|
||||
::sm/params schema:update-file-snapshot}
|
||||
[cfg params]
|
||||
(db/tx-run! cfg update-file-snapshot! params))
|
||||
|
||||
(def ^:private schema:remove-file-snapshot
|
||||
[:map {:title "remove-file-snapshot"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(defn remove-file-snapshot!
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id]}]
|
||||
(db/delete! conn :file-change
|
||||
{:id id :created-by "user"}
|
||||
{::db/return-keys false})
|
||||
nil)
|
||||
|
||||
(sv/defmethod ::remove-file-snapshot
|
||||
{::doc/added "1.20"
|
||||
::sm/params schema:remove-file-snapshot}
|
||||
[cfg params]
|
||||
(db/tx-run! cfg remove-file-snapshot! params))
|
||||
|
||||
|
||||
|
|
|
@ -60,6 +60,7 @@
|
|||
[:id ::sm/uuid]
|
||||
[:session-id ::sm/uuid]
|
||||
[:revn {:min 0} ::sm/int]
|
||||
[:vern {:min 0} ::sm/int]
|
||||
[:features {:optional true} ::cfeat/features]
|
||||
[:changes {:optional true} [:vector ::cpc/change]]
|
||||
[:changes-with-metadata {:optional true}
|
||||
|
@ -157,6 +158,14 @@
|
|||
tpoint (dt/tpoint)]
|
||||
|
||||
|
||||
(when (not= (:vern params)
|
||||
(:vern file))
|
||||
(ex/raise :type :validation
|
||||
:code :vern-conflict
|
||||
:hint "A different version has been restored for the file."
|
||||
:context {:incoming-revn (:revn params)
|
||||
:stored-revn (:revn file)}))
|
||||
|
||||
(when (> (:revn params)
|
||||
(:revn file))
|
||||
(ex/raise :type :validation
|
||||
|
@ -455,7 +464,7 @@
|
|||
"SELECT fch.id, fch.created_at
|
||||
FROM file_change AS fch
|
||||
WHERE fch.file_id = ?
|
||||
AND fch.label LIKE 'internal/%'
|
||||
AND fch.created_by = 'system'
|
||||
ORDER BY fch.created_at DESC
|
||||
LIMIT ?")
|
||||
|
||||
|
@ -465,7 +474,7 @@
|
|||
"UPDATE file_change
|
||||
SET label = NULL
|
||||
WHERE file_id = ?
|
||||
AND label LIKE 'internal/%'
|
||||
AND created_by LIKE 'system'
|
||||
AND created_at < ?")
|
||||
|
||||
(defn- delete-old-snapshots!
|
||||
|
@ -502,6 +511,7 @@
|
|||
:file-id (:id file)
|
||||
:session-id (:session-id params)
|
||||
:revn (:revn file)
|
||||
:vern (:vern file)
|
||||
:changes changes})
|
||||
|
||||
(when (and (:is-shared file) (seq lchanges))
|
||||
|
|
|
@ -336,8 +336,7 @@
|
|||
|
||||
(defn take-team-snapshot!
|
||||
[team-id & {:keys [label rollback?] :or {rollback? true}}]
|
||||
(let [team-id (h/parse-uuid team-id)
|
||||
label (or label (fsnap/generate-snapshot-label))]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(-> (assoc main/system ::db/rollback rollback?)
|
||||
(db/tx-run! h/take-team-snapshot! team-id label))))
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@
|
|||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn revn
|
||||
:vern 0
|
||||
:features cfeat/supported-features
|
||||
:changes changes}
|
||||
out (th/command! params)]
|
||||
|
@ -55,6 +56,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-page
|
||||
:name "test 1"
|
||||
|
@ -67,6 +69,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id-1
|
||||
|
|
|
@ -312,6 +312,7 @@
|
|||
(#'files.update/update-file* system
|
||||
{:id file-id
|
||||
:revn revn
|
||||
:vern 0
|
||||
:file file
|
||||
:features (:features file)
|
||||
:changes changes
|
||||
|
@ -327,6 +328,7 @@
|
|||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn revn
|
||||
:vern 0
|
||||
:features features
|
||||
:changes changes}
|
||||
out (command! params)]
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn revn
|
||||
:vern 0
|
||||
:features cfeat/supported-features
|
||||
:changes changes}
|
||||
out (th/command! params)]
|
||||
|
@ -147,6 +148,7 @@
|
|||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn revn
|
||||
:vern 0
|
||||
:features cfeat/supported-features
|
||||
:changes changes}
|
||||
out (th/command! params)]
|
||||
|
@ -174,6 +176,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-page
|
||||
:name "test"
|
||||
|
@ -203,6 +206,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
|
@ -279,6 +283,7 @@
|
|||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn revn
|
||||
:vern 0
|
||||
:features cfeat/supported-features
|
||||
:changes changes}
|
||||
out (th/command! params)]
|
||||
|
@ -305,6 +310,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
|
@ -367,6 +373,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes [{:type :del-obj
|
||||
:page-id (first (get-in file [:data :pages]))
|
||||
:id shid}])
|
||||
|
@ -418,6 +425,7 @@
|
|||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn revn
|
||||
:vern 0
|
||||
:components-v2 true
|
||||
:changes changes}
|
||||
out (th/command! params)]
|
||||
|
@ -452,6 +460,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
|
@ -528,6 +537,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes [{:type :del-obj
|
||||
:page-id (first (get-in file [:data :pages]))
|
||||
:id s-shid}
|
||||
|
@ -622,6 +632,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
|
@ -688,6 +699,7 @@
|
|||
:file-id file-id
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes [{:type :del-obj
|
||||
:page-id page-id
|
||||
:id frame-id-2}])
|
||||
|
@ -721,6 +733,7 @@
|
|||
:file-id file-id
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes [{:type :del-obj
|
||||
:page-id page-id
|
||||
:id frame-id-1}])
|
||||
|
@ -978,6 +991,7 @@
|
|||
(th/update-file* {:file-id (:id file)
|
||||
:profile-id (:id prof)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:components-v2 true
|
||||
:changes changes})
|
||||
|
||||
|
@ -1178,6 +1192,7 @@
|
|||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:revn 2
|
||||
:vern 0
|
||||
:is-shared false})]
|
||||
|
||||
(t/testing "create a file thumbnail"
|
||||
|
@ -1286,6 +1301,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-page
|
||||
:name "test"
|
||||
|
|
|
@ -42,6 +42,7 @@
|
|||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
|
@ -253,7 +254,8 @@
|
|||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false
|
||||
:revn 3})
|
||||
:revn 3
|
||||
:vern 0})
|
||||
|
||||
data1 {::th/type :create-file-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn revn
|
||||
:vern 0
|
||||
:features cfeat/supported-features
|
||||
:changes changes}
|
||||
out (th/command! params)]
|
||||
|
@ -65,6 +66,7 @@
|
|||
:file-id (:id file1)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-media
|
||||
:object mobj}])
|
||||
|
@ -195,6 +197,7 @@
|
|||
:file-id (:id file1)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-media
|
||||
:object mobj}])
|
||||
|
|
|
@ -134,6 +134,7 @@
|
|||
:project-id project-id
|
||||
:name name
|
||||
:revn revn
|
||||
:vern 0
|
||||
:is-shared is-shared
|
||||
:version version
|
||||
:data data
|
||||
|
|
1
frontend/resources/images/icons/clock.svg
Normal file
1
frontend/resources/images/icons/clock.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="14" xmlns="http://www.w3.org/2000/svg" height="14" id="screenshot-4156406f-352e-8098-8005-2199804fae83" viewBox="2888.82 1433.645 14 14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" stroke-linecap="round"><path d="M2895.820,1445.645C2898.581,1445.645,2900.820,1443.407,2900.820,1440.645C2900.820,1437.884,2898.581,1435.645,2895.820,1435.645C2893.058,1435.645,2890.820,1437.884,2890.820,1440.645C2890.820,1443.407,2893.058,1445.645,2895.820,1445.645ZM2897.820,1441.645L2895.820,1440.645L2895.820,1437.645" style="stroke-linecap: round;"/></svg>
|
After Width: | Height: | Size: 568 B |
|
@ -106,7 +106,7 @@
|
|||
(defn commit
|
||||
"Create a commit event instance"
|
||||
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
|
||||
file-id file-revn undo-group tags stack-undo? source]}]
|
||||
file-id file-revn file-vern undo-group tags stack-undo? source]}]
|
||||
|
||||
(dm/assert!
|
||||
"expect valid vector of changes for redo-changes"
|
||||
|
@ -126,6 +126,7 @@
|
|||
:features features
|
||||
:file-id file-id
|
||||
:file-revn file-revn
|
||||
:file-vern file-vern
|
||||
:changes redo-changes
|
||||
:redo-changes redo-changes
|
||||
:undo-changes undo-changes
|
||||
|
@ -160,6 +161,13 @@
|
|||
(:revn file)
|
||||
(dm/get-in state [:workspace-libraries file-id :revn]))))
|
||||
|
||||
(defn- resolve-file-vern
|
||||
[state file-id]
|
||||
(let [file (:workspace-file state)]
|
||||
(if (= (:id file) file-id)
|
||||
(:vern file)
|
||||
(dm/get-in state [:workspace-libraries file-id :vern]))))
|
||||
|
||||
(defn commit-changes
|
||||
"Schedules a list of changes to execute now, and add the corresponding undo changes to
|
||||
the undo stack.
|
||||
|
@ -194,6 +202,7 @@
|
|||
(assoc :save-undo? save-undo?)
|
||||
(assoc :file-id file-id)
|
||||
(assoc :file-revn (resolve-file-revn state file-id))
|
||||
(assoc :file-vern (resolve-file-vern state file-id))
|
||||
(assoc :undo-changes uchg)
|
||||
(assoc :redo-changes rchg)
|
||||
(commit))))))))
|
||||
|
|
|
@ -108,11 +108,12 @@
|
|||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(log/dbg :hint "persist-commit" :commit-id (dm/str commit-id))
|
||||
(when-let [{:keys [file-id file-revn changes features] :as commit} (dm/get-in state [:persistence :index commit-id])]
|
||||
(when-let [{:keys [file-id file-revn file-vern changes features] :as commit} (dm/get-in state [:persistence :index commit-id])]
|
||||
(let [sid (:session-id state)
|
||||
revn (max file-revn (get @revn-data file-id 0))
|
||||
params {:id file-id
|
||||
:revn revn
|
||||
:vern file-vern
|
||||
:session-id sid
|
||||
:origin (:origin commit)
|
||||
:created-at (:created-at commit)
|
||||
|
|
|
@ -70,6 +70,7 @@
|
|||
[app.main.data.workspace.undo :as dwu]
|
||||
[app.main.data.workspace.viewport :as dwv]
|
||||
[app.main.data.workspace.zoom :as dwz]
|
||||
[app.main.errors]
|
||||
[app.main.features :as features]
|
||||
[app.main.features.pointer-map :as fpmap]
|
||||
[app.main.repo :as rp]
|
||||
|
@ -378,6 +379,19 @@
|
|||
(let [name (dm/str "workspace-" file-id)]
|
||||
(unchecked-set ug/global "name" name)))))
|
||||
|
||||
(defn reload-file
|
||||
[]
|
||||
(ptk/reify ::reload-file
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [file-id (:current-file-id state)
|
||||
project-id (:current-project-id state)]
|
||||
(rx/of (initialize-file project-id file-id))))))
|
||||
|
||||
;; We need to inject this so there are no cycles
|
||||
(set! app.main.data.workspace.notifications/reload-file reload-file)
|
||||
(set! app.main.errors/reload-file reload-file)
|
||||
|
||||
(defn finalize-file
|
||||
[_project-id file-id]
|
||||
(ptk/reify ::finalize-file
|
||||
|
|
|
@ -29,12 +29,16 @@
|
|||
[clojure.set :as set]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
;; From app.main.data.workspace we can use directly because it causes a circular dependency
|
||||
(def reload-file nil)
|
||||
|
||||
;; FIXME: this ns should be renamed to something different
|
||||
|
||||
(declare process-message)
|
||||
(declare handle-presence)
|
||||
(declare handle-pointer-update)
|
||||
(declare handle-file-change)
|
||||
(declare handle-file-restore)
|
||||
(declare handle-library-change)
|
||||
(declare handle-pointer-send)
|
||||
(declare handle-export-update)
|
||||
|
@ -124,6 +128,7 @@
|
|||
:disconnect (handle-presence msg)
|
||||
:pointer-update (handle-pointer-update msg)
|
||||
:file-change (handle-file-change msg)
|
||||
:file-restore (handle-file-restore msg)
|
||||
:library-change (handle-library-change msg)
|
||||
:notification (dc/handle-notification msg)
|
||||
:team-role-change (handle-change-team-role msg)
|
||||
|
@ -229,13 +234,14 @@
|
|||
[:file-id ::sm/uuid]
|
||||
[:session-id ::sm/uuid]
|
||||
[:revn :int]
|
||||
[:vern :int]
|
||||
[:changes ::cpc/changes]])
|
||||
|
||||
(def ^:private check-file-change-params!
|
||||
(sm/check-fn schema:handle-file-change))
|
||||
|
||||
(defn handle-file-change
|
||||
[{:keys [file-id changes revn] :as msg}]
|
||||
[{:keys [file-id changes revn vern] :as msg}]
|
||||
|
||||
(dm/assert!
|
||||
"expected valid parameters"
|
||||
|
@ -250,13 +256,41 @@
|
|||
;; The commit event is responsible to apply the data localy
|
||||
;; and update the persistence internal state with the updated
|
||||
;; file-revn
|
||||
|
||||
(rx/of (dch/commit {:file-id file-id
|
||||
:file-revn revn
|
||||
:file-vern vern
|
||||
:save-undo? false
|
||||
:source :remote
|
||||
:redo-changes (vec changes)
|
||||
:undo-changes []})))))
|
||||
|
||||
(def ^:private
|
||||
schema:handle-file-restore
|
||||
[:map {:title "handle-file-restore"}
|
||||
[:type :keyword]
|
||||
[:file-id ::sm/uuid]
|
||||
[:vern :int]])
|
||||
|
||||
(def ^:private check-file-restore-params!
|
||||
(sm/check-fn schema:handle-file-restore))
|
||||
|
||||
(defn handle-file-restore
|
||||
[{:keys [file-id vern] :as msg}]
|
||||
|
||||
(dm/assert!
|
||||
"expected valid parameters"
|
||||
(check-file-restore-params! msg))
|
||||
|
||||
(ptk/reify ::handle-file-restore
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [curr-file-id (:current-file-id state)
|
||||
curr-vern (dm/get-in state [:workspace-file :vern])
|
||||
reload? (and (= file-id curr-file-id) (not= vern curr-vern))]
|
||||
(when reload?
|
||||
(rx/of (reload-file)))))))
|
||||
|
||||
(def ^:private schema:handle-library-change
|
||||
[:map {:title "handle-library-change"}
|
||||
[:type :keyword]
|
||||
|
|
131
frontend/src/app/main/data/workspace/versions.cljs
Normal file
131
frontend/src/app/main/data/workspace/versions.cljs
Normal file
|
@ -0,0 +1,131 @@
|
|||
;; 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 INC
|
||||
|
||||
(ns app.main.data.workspace.versions
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.data.persistence :as dwp]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.repo :as rp]
|
||||
[app.util.time :as dt]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defonce default-state
|
||||
{:status :loading
|
||||
:data nil
|
||||
:editing nil})
|
||||
|
||||
(declare fetch-versions)
|
||||
|
||||
(defn init-version-state
|
||||
[file-id]
|
||||
(ptk/reify ::init-version-state
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :workspace-versions default-state))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (fetch-versions file-id)))))
|
||||
|
||||
(defn update-version-state
|
||||
[version-state]
|
||||
(ptk/reify ::update-version-state
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :workspace-versions merge version-state))))
|
||||
|
||||
(defn fetch-versions
|
||||
[file-id]
|
||||
(dm/assert! (uuid? file-id))
|
||||
(ptk/reify ::fetch-versions
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :get-file-snapshots {:file-id file-id})
|
||||
(rx/map #(update-version-state {:status :loaded :data %}))))))
|
||||
|
||||
(defn create-version
|
||||
[file-id]
|
||||
(dm/assert! (uuid? file-id))
|
||||
(ptk/reify ::create-version
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(let [label (dt/format (dt/now) :date-full)]
|
||||
;; Force persist before creating snapshot, otherwise we could loss changes
|
||||
(rx/concat
|
||||
(rx/of ::dwp/force-persist)
|
||||
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
|
||||
(rx/filter #(or (nil? %) (= :saved %)))
|
||||
(rx/take 1)
|
||||
(rx/mapcat #(rp/cmd! :take-file-snapshot {:file-id file-id :label label}))
|
||||
(rx/mapcat
|
||||
(fn [{:keys [id]}]
|
||||
(rx/of
|
||||
(update-version-state {:editing id})
|
||||
(fetch-versions file-id))))))))))
|
||||
|
||||
(defn rename-version
|
||||
[file-id id label]
|
||||
(dm/assert! (uuid? file-id))
|
||||
(dm/assert! (uuid? id))
|
||||
(dm/assert! (and (string? label) (d/not-empty? label)))
|
||||
|
||||
(ptk/reify ::rename-version
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/merge
|
||||
(rx/of (update-version-state {:editing false}))
|
||||
(->> (rp/cmd! :update-file-snapshot {:id id :label label})
|
||||
(rx/map #(fetch-versions file-id)))))))
|
||||
|
||||
(defn restore-version
|
||||
[project-id file-id id]
|
||||
(dm/assert! (uuid? project-id))
|
||||
(dm/assert! (uuid? file-id))
|
||||
(dm/assert! (uuid? id))
|
||||
|
||||
(ptk/reify ::restore-version
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/concat
|
||||
(rx/of ::dwp/force-persist)
|
||||
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
|
||||
(rx/filter #(or (nil? %) (= :saved %)))
|
||||
(rx/take 1)
|
||||
(rx/mapcat #(rp/cmd! :take-file-snapshot {:file-id file-id :created-by "system" :label (dt/format (dt/now) :date-full)}))
|
||||
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
|
||||
(rx/map #(dw/initialize-file project-id file-id)))))))
|
||||
|
||||
(defn delete-version
|
||||
[file-id id]
|
||||
(dm/assert! (uuid? file-id))
|
||||
(dm/assert! (uuid? id))
|
||||
|
||||
(ptk/reify ::delete-version
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :remove-file-snapshot {:id id})
|
||||
(rx/map #(fetch-versions file-id))))))
|
||||
|
||||
(defn pin-version
|
||||
[file-id id]
|
||||
(dm/assert! (uuid? file-id))
|
||||
(dm/assert! (uuid? id))
|
||||
|
||||
(ptk/reify ::pin-version
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [version (->> (dm/get-in state [:workspace-versions :data])
|
||||
(d/seek #(= (:id %) id)))
|
||||
params {:id id
|
||||
:label (dt/format (:created-at version) :date-full)}]
|
||||
|
||||
(->> (rp/cmd! :update-file-snapshot params)
|
||||
(rx/mapcat #(rx/of (update-version-state {:editing id})
|
||||
(fetch-versions file-id))))))))
|
|
@ -21,6 +21,9 @@
|
|||
[cuerdas.core :as str]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
;; From app.main.data.workspace we can use directly because it causes a circular dependency
|
||||
(def reload-file nil)
|
||||
|
||||
(defn- print-data!
|
||||
[data]
|
||||
(-> data
|
||||
|
@ -137,6 +140,9 @@
|
|||
:level :error
|
||||
:timeout 3000})))
|
||||
|
||||
(= code :vern-conflict)
|
||||
(st/emit! (reload-file))
|
||||
|
||||
:else
|
||||
(st/async-emit! (rt/assign-exception error))))
|
||||
|
||||
|
|
|
@ -591,6 +591,9 @@
|
|||
(def current-file-id
|
||||
(l/derived :current-file-id st/state))
|
||||
|
||||
(def current-project-id
|
||||
(l/derived :current-project-id st/state))
|
||||
|
||||
(def workspace-preview-blend
|
||||
(l/derived :workspace-preview-blend st/state))
|
||||
|
||||
|
@ -604,4 +607,4 @@
|
|||
(l/derived :updating-library st/state))
|
||||
|
||||
(def persistence-state
|
||||
(l/derived (comp :status :workspace-persistence) st/state))
|
||||
(l/derived (comp :status :persistence) st/state))
|
||||
|
|
|
@ -88,6 +88,7 @@
|
|||
(def ^:icon-id character-z "character-z")
|
||||
(def ^:icon-id clip-content "clip-content")
|
||||
(def ^:icon-id clipboard "clipboard")
|
||||
(def ^:icon-id clock "clock")
|
||||
(def ^:icon-id close-small "close-small")
|
||||
(def ^:icon-id close "close")
|
||||
(def ^:icon-id code "code")
|
||||
|
|
|
@ -72,6 +72,7 @@
|
|||
(def ^:icon bug (icon-xref :bug))
|
||||
(def ^:icon clip-content (icon-xref :clip-content))
|
||||
(def ^:icon clipboard (icon-xref :clipboard))
|
||||
(def ^:icon clock (icon-xref :clock))
|
||||
(def ^:icon close-small (icon-xref :close-small))
|
||||
(def ^:icon close (icon-xref :close))
|
||||
(def ^:icon code (icon-xref :code))
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
[app.main.ui.workspace.sidebar.options :refer [options-toolbox]]
|
||||
[app.main.ui.workspace.sidebar.shortcuts :refer [shortcuts-container]]
|
||||
[app.main.ui.workspace.sidebar.sitemap :refer [sitemap]]
|
||||
[app.main.ui.workspace.sidebar.versions :refer [versions-toolbox]]
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[rumext.v2 :as mf]))
|
||||
|
@ -181,7 +182,17 @@
|
|||
props
|
||||
(mf/spread props
|
||||
:on-change-section handle-change-section
|
||||
:on-expand handle-expand)]
|
||||
:on-expand handle-expand)
|
||||
|
||||
history-tab
|
||||
(mf/html
|
||||
[:article {:class (stl/css :history-tab)}
|
||||
[:& history-toolbox {}]])
|
||||
|
||||
versions-tab
|
||||
(mf/html
|
||||
[:article {:class (stl/css :versions-tab)}
|
||||
[:& versions-toolbox {}]])]
|
||||
|
||||
[:& (mf/provider muc/sidebar) {:value :right}
|
||||
[:aside {:class (stl/css-case :right-settings-bar true
|
||||
|
@ -208,7 +219,15 @@
|
|||
[:& comments-sidebar]
|
||||
|
||||
(true? is-history?)
|
||||
[:> history-toolbox {}]
|
||||
[:> tab-switcher* {:tabs #js [#js {:label "History" :id "history" :content versions-tab}
|
||||
#js {:label "Actions" :id "actions" :content history-tab}]
|
||||
:default-selected "history"
|
||||
;;:selected (name section)
|
||||
;;:on-change-tab on-tab-change
|
||||
:class (stl/css :left-sidebar-tabs)
|
||||
;;:action-button-position "start"
|
||||
;;:action-button (mf/html [:& collapse-button {:on-click handle-collapse}])
|
||||
}]
|
||||
|
||||
:else
|
||||
[:> options-toolbox props])]]]))
|
||||
|
|
|
@ -109,3 +109,8 @@ $width-settings-bar-max: $s-500;
|
|||
--collapse-icon-color: var(--color-foreground-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.versions-tab {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
@ -9,12 +9,9 @@
|
|||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.data.events :as ev]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.icons :as i]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :refer [tr] :as i18n]
|
||||
|
@ -326,18 +323,8 @@
|
|||
[]
|
||||
(let [objects (mf/deref refs/workspace-page-objects)
|
||||
{:keys [items index]} (mf/deref workspace-undo)
|
||||
entries (parse-entries items objects)
|
||||
toggle-history
|
||||
(mf/use-fn
|
||||
#(st/emit! (-> (dw/toggle-layout-flag :document-history)
|
||||
(vary-meta assoc ::ev/origin "history-toolbox"))))]
|
||||
entries (parse-entries items objects)]
|
||||
[:div {:class (stl/css :history-toolbox)}
|
||||
[:div {:class (stl/css :history-toolbox-title)}
|
||||
[:span (tr "workspace.undo.title")]
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click toggle-history
|
||||
:icon "close"}]]
|
||||
(if (empty? entries)
|
||||
[:div {:class (stl/css :history-entry-empty)}
|
||||
[:div {:class (stl/css :history-entry-empty-icon)} i/history]
|
||||
|
|
377
frontend/src/app/main/ui/workspace/sidebar/versions.cljs
Normal file
377
frontend/src/app/main/ui/workspace/sidebar/versions.cljs
Normal file
|
@ -0,0 +1,377 @@
|
|||
;; 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 INC
|
||||
|
||||
(ns app.main.ui.workspace.sidebar.versions
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cfg]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.workspace.versions :as dwv]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
[app.main.ui.components.select :refer [select]]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as kbd]
|
||||
[app.util.time :as dt]
|
||||
[cuerdas.core :as str]
|
||||
[okulary.core :as l]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def versions
|
||||
(l/derived :workspace-versions st/state))
|
||||
|
||||
(defn group-snapshots
|
||||
[data]
|
||||
(->> (concat
|
||||
(->> data
|
||||
(filterv #(= "user" (:created-by %)))
|
||||
(map #(assoc % :type :version)))
|
||||
(->> data
|
||||
(filterv #(= "system" (:created-by %)))
|
||||
(group-by #(.toISODate ^js (:created-at %)))
|
||||
(map (fn [[day entries]]
|
||||
{:type :snapshot
|
||||
:created-at (ct/parse-instant day)
|
||||
:snapshots entries}))))
|
||||
(sort-by :created-at)
|
||||
(reverse)))
|
||||
|
||||
(mf/defc version-entry
|
||||
[{:keys [entry profile on-restore-version on-delete-version on-rename-version editing?]}]
|
||||
(let [input-ref (mf/use-ref nil)
|
||||
|
||||
show-menu? (mf/use-state false)
|
||||
|
||||
handle-open-menu
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(reset! show-menu? true)))
|
||||
|
||||
handle-close-menu
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(reset! show-menu? false)))
|
||||
|
||||
handle-rename-version
|
||||
(mf/use-fn
|
||||
(mf/deps entry)
|
||||
(fn []
|
||||
(st/emit! (dwv/update-version-state {:editing (:id entry)}))))
|
||||
|
||||
handle-restore-version
|
||||
(mf/use-fn
|
||||
(mf/deps entry on-restore-version)
|
||||
(fn []
|
||||
(when on-restore-version
|
||||
(on-restore-version (:id entry)))))
|
||||
|
||||
handle-delete-version
|
||||
(mf/use-callback
|
||||
(mf/deps entry on-delete-version)
|
||||
(fn []
|
||||
(when on-delete-version
|
||||
(on-delete-version (:id entry)))))
|
||||
|
||||
handle-name-input-focus
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(dom/select-text! (dom/get-target event))))
|
||||
|
||||
handle-name-input-blur
|
||||
(mf/use-fn
|
||||
(mf/deps entry on-rename-version)
|
||||
(fn [event]
|
||||
(let [label (str/trim (dom/get-target-val event))]
|
||||
(when (and (not (str/empty? label))
|
||||
(some? on-rename-version))
|
||||
(on-rename-version (:id entry) label))
|
||||
(st/emit! (dwv/update-version-state {:editing nil})))))
|
||||
|
||||
handle-name-input-key-down
|
||||
(mf/use-fn
|
||||
(mf/deps handle-name-input-blur)
|
||||
(fn [event]
|
||||
(cond
|
||||
(kbd/enter? event)
|
||||
(handle-name-input-blur event)
|
||||
|
||||
(kbd/esc? event)
|
||||
(st/emit! (dwv/update-version-state {:editing nil})))))]
|
||||
|
||||
[:li {:class (stl/css :version-entry-wrap)}
|
||||
[:div {:class (stl/css :version-entry :is-snapshot)}
|
||||
[:img {:class (stl/css :version-entry-avatar)
|
||||
:alt (:fullname profile)
|
||||
:src (cfg/resolve-profile-photo-url profile)}]
|
||||
|
||||
[:div {:class (stl/css :version-entry-data)}
|
||||
(if editing?
|
||||
[:input {:class (stl/css :version-entry-name-edit)
|
||||
:type "text"
|
||||
:ref input-ref
|
||||
:on-focus handle-name-input-focus
|
||||
:on-blur handle-name-input-blur
|
||||
:on-key-down handle-name-input-key-down
|
||||
:auto-focus true
|
||||
:default-value (:label entry)}]
|
||||
|
||||
[:p {:class (stl/css :version-entry-name)}
|
||||
(:label entry)])
|
||||
|
||||
[:p {:class (stl/css :version-entry-time)}
|
||||
(let [locale (mf/deref i18n/locale)
|
||||
time (dt/timeago (:created-at entry) {:locale locale})]
|
||||
[:span {:class (stl/css :date)} time])]]
|
||||
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.versions.version-menu")
|
||||
:on-click handle-open-menu
|
||||
:icon "menu"}]]
|
||||
|
||||
[:& dropdown {:show @show-menu? :on-close handle-close-menu}
|
||||
[:ul {:class (stl/css :version-options-dropdown)}
|
||||
[:li {:class (stl/css :menu-option)
|
||||
:role "button"
|
||||
:on-click handle-rename-version} (tr "labels.rename")]
|
||||
[:li {:class (stl/css :menu-option)
|
||||
:role "button"
|
||||
:on-click handle-restore-version} (tr "labels.restore")]
|
||||
[:li {:class (stl/css :menu-option)
|
||||
:role "button"
|
||||
:on-click handle-delete-version} (tr "labels.delete")]]]]))
|
||||
|
||||
(mf/defc snapshot-entry
|
||||
[{:keys [index is-expanded entry on-toggle-expand on-pin-snapshot on-restore-snapshot]}]
|
||||
|
||||
(let [open-menu (mf/use-state nil)
|
||||
|
||||
handle-toggle-expand
|
||||
(mf/use-fn
|
||||
(mf/deps index on-toggle-expand)
|
||||
(fn []
|
||||
(when on-toggle-expand
|
||||
(on-toggle-expand index))))
|
||||
|
||||
handle-pin-snapshot
|
||||
(mf/use-fn
|
||||
(mf/deps on-pin-snapshot)
|
||||
(fn [event]
|
||||
(let [node (dom/get-current-target event)
|
||||
id (-> (dom/get-data node "id") uuid/uuid)]
|
||||
(when on-pin-snapshot (on-pin-snapshot id)))))
|
||||
|
||||
handle-restore-snapshot
|
||||
(mf/use-fn
|
||||
(mf/deps on-restore-snapshot)
|
||||
(fn [event]
|
||||
(let [node (dom/get-current-target event)
|
||||
id (-> (dom/get-data node "id") uuid/uuid)]
|
||||
(when on-restore-snapshot (on-restore-snapshot id)))))]
|
||||
|
||||
[:li {:class (stl/css :version-entry-wrap)}
|
||||
[:div {:class (stl/css-case :version-entry true
|
||||
:is-autosave true
|
||||
:is-expanded is-expanded)}
|
||||
[:p {:class (stl/css :version-entry-name)}
|
||||
(tr "workspace.versions.autosaved.version" (dt/format (:created-at entry) :date-full))]
|
||||
|
||||
[:button {:class (stl/css :version-entry-snapshots)
|
||||
:aria-label (tr "workspace.versions.expand-snapshot")
|
||||
:on-click handle-toggle-expand}
|
||||
[:> i/icon* {:id i/clock :class (stl/css :icon-clock)}]
|
||||
(tr "workspace.versions.autosaved.entry" (count (:snapshots entry)))
|
||||
[:> i/icon* {:id i/arrow :class (stl/css :icon-arrow)}]]
|
||||
|
||||
[:ul {:class (stl/css :version-snapshot-list)}
|
||||
(for [[idx snapshot] (d/enumerate (:snapshots entry))]
|
||||
[:li {:class (stl/css :version-snapshot-entry-wrapper)
|
||||
:key (dm/str "snp-" idx)}
|
||||
[:div {:class (stl/css :version-snapshot-entry)}
|
||||
(str
|
||||
(dt/format (:created-at snapshot) :date-full)
|
||||
" . "
|
||||
(dt/format (:created-at snapshot) :time-24-simple))]
|
||||
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.versions.snapshot-menu")
|
||||
:on-click #(reset! open-menu snapshot)
|
||||
:icon "menu"
|
||||
:class (stl/css :version-snapshot-menu-btn)}]
|
||||
|
||||
[:& dropdown {:show (= @open-menu snapshot)
|
||||
:on-close #(reset! open-menu nil)}
|
||||
[:ul {:class (stl/css :version-options-dropdown)}
|
||||
[:li {:class (stl/css :menu-option)
|
||||
:role "button"
|
||||
:data-id (dm/str (:id snapshot))
|
||||
:on-click handle-restore-snapshot}
|
||||
(tr "workspace.versions.button.restore")]
|
||||
[:li {:class (stl/css :menu-option)
|
||||
:role "button"
|
||||
:data-id (dm/str (:id snapshot))
|
||||
:on-click handle-pin-snapshot}
|
||||
(tr "workspace.versions.button.pin")]]]])]]]))
|
||||
|
||||
(mf/defc versions-toolbox
|
||||
[]
|
||||
(let [users (mf/deref refs/users)
|
||||
profile (mf/deref refs/profile)
|
||||
project-id (mf/deref refs/current-project-id)
|
||||
file-id (mf/deref refs/current-file-id)
|
||||
expanded (mf/use-state #{})
|
||||
|
||||
{:keys [status data editing]}
|
||||
(mf/deref versions)
|
||||
|
||||
;; Store users that have a version
|
||||
data-users
|
||||
(mf/use-memo
|
||||
(mf/deps data)
|
||||
(fn []
|
||||
(into #{} (keep (fn [{:keys [created-by profile-id]}]
|
||||
(when (= "user" created-by) profile-id))) data)))
|
||||
|
||||
data
|
||||
(mf/use-memo
|
||||
(mf/deps @versions)
|
||||
(fn []
|
||||
(->> data
|
||||
(filter #(or (not (:filter @versions))
|
||||
(and
|
||||
(= "user" (:created-by %))
|
||||
(= (:filter @versions) (:profile-id %)))))
|
||||
(group-snapshots))))
|
||||
|
||||
handle-create-version
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (dwv/create-version file-id))))
|
||||
|
||||
handle-toggle-expand
|
||||
(mf/use-fn
|
||||
(fn [id]
|
||||
(swap! expanded
|
||||
(fn [expanded]
|
||||
(let [has-element? (contains? expanded id)]
|
||||
(cond-> expanded
|
||||
has-element? (disj id)
|
||||
(not has-element?) (conj id)))))))
|
||||
|
||||
handle-rename-version
|
||||
(mf/use-fn
|
||||
(mf/deps file-id)
|
||||
(fn [id label]
|
||||
(st/emit! (dwv/rename-version file-id id label))))
|
||||
|
||||
handle-restore-version
|
||||
(mf/use-fn
|
||||
(mf/deps project-id file-id)
|
||||
(fn [id]
|
||||
(st/emit!
|
||||
(ntf/dialog
|
||||
:content (tr "workspace.versions.restore-warning")
|
||||
:controls :inline-actions
|
||||
:actions [{:label (tr "workspace.updates.dismiss")
|
||||
:type :secondary
|
||||
:callback #(st/emit! (ntf/hide))}
|
||||
{:label (tr "labels.restore")
|
||||
:type :primary
|
||||
:callback #(st/emit! (dwv/restore-version project-id file-id id))}]
|
||||
:tag :restore-dialog))))
|
||||
|
||||
handle-delete-version
|
||||
(mf/use-fn
|
||||
(mf/deps file-id)
|
||||
(fn [id]
|
||||
(st/emit! (dwv/delete-version file-id id))))
|
||||
|
||||
handle-pin-version
|
||||
(mf/use-fn
|
||||
(mf/deps file-id)
|
||||
(fn [id]
|
||||
(st/emit! (dwv/pin-version file-id id))))
|
||||
|
||||
handle-change-filter
|
||||
(mf/use-fn
|
||||
(fn [filter]
|
||||
(cond
|
||||
(= :all filter)
|
||||
(st/emit! (dwv/update-version-state {:filter nil}))
|
||||
|
||||
(= :own filter)
|
||||
(st/emit! (dwv/update-version-state {:filter (:id profile)}))
|
||||
|
||||
:else
|
||||
(st/emit! (dwv/update-version-state {:filter filter})))))]
|
||||
|
||||
(mf/with-effect
|
||||
[file-id]
|
||||
(when file-id
|
||||
(st/emit! (dwv/init-version-state file-id))))
|
||||
|
||||
[:div {:class (stl/css :version-toolbox)}
|
||||
[:& select
|
||||
{:default-value :all
|
||||
:aria-label (tr "workspace.versions.filter.label")
|
||||
:options (into [{:value :all :label (tr "workspace.versions.filter.all")}
|
||||
{:value :own :label (tr "workspace.versions.filter.mine")}]
|
||||
(->> data-users
|
||||
(keep
|
||||
(fn [id]
|
||||
(let [{:keys [fullname]} (get users id)]
|
||||
(when (not= id (:id profile))
|
||||
{:value id :label (tr "workspace.versions.filter.user" fullname)}))))))
|
||||
:on-change handle-change-filter}]
|
||||
|
||||
(cond
|
||||
(= status :loading)
|
||||
[:div {:class (stl/css :versions-entry-empty)}
|
||||
[:div {:class (stl/css :versions-entry-empty-msg)} (tr "workspace.versions.loading")]]
|
||||
|
||||
(= status :loaded)
|
||||
[:*
|
||||
[:div {:class (stl/css :version-save-version)}
|
||||
(tr "workspace.versions.button.save")
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.versions.button.save")
|
||||
:on-click handle-create-version
|
||||
:icon "pin"}]]
|
||||
|
||||
(if (empty? data)
|
||||
[:div {:class (stl/css :versions-entry-empty)}
|
||||
[:div {:class (stl/css :versions-entry-empty-icon)} [:> i/icon* {:id i/history}]]
|
||||
[:div {:class (stl/css :versions-entry-empty-msg)} (tr "workspace.versions.empty")]]
|
||||
|
||||
[:ul {:class (stl/css :versions-entries)}
|
||||
(for [[idx-entry entry] (->> data (map-indexed vector))]
|
||||
(case (:type entry)
|
||||
:version
|
||||
[:& version-entry {:key idx-entry
|
||||
:entry entry
|
||||
:editing? (= (:id entry) editing)
|
||||
:profile (get users (:profile-id entry))
|
||||
:on-rename-version handle-rename-version
|
||||
:on-restore-version handle-restore-version
|
||||
:on-delete-version handle-delete-version}]
|
||||
|
||||
:snapshot
|
||||
[:& snapshot-entry {:key idx-entry
|
||||
:index idx-entry
|
||||
:entry entry
|
||||
:is-expanded (contains? @expanded idx-entry)
|
||||
:on-toggle-expand handle-toggle-expand
|
||||
:on-restore-snapshot handle-restore-version
|
||||
:on-pin-snapshot handle-pin-version}]
|
||||
|
||||
nil))])])]))
|
226
frontend/src/app/main/ui/workspace/sidebar/versions.scss
Normal file
226
frontend/src/app/main/ui/workspace/sidebar/versions.scss
Normal file
|
@ -0,0 +1,226 @@
|
|||
// 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 INC
|
||||
|
||||
@import "refactor/common-refactor.scss";
|
||||
|
||||
.version-toolbox {
|
||||
padding: $s-8;
|
||||
}
|
||||
|
||||
.versions-entry-empty {
|
||||
align-items: center;
|
||||
color: var(--color-foreground-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: $fs-12;
|
||||
gap: $s-8;
|
||||
padding: $s-16;
|
||||
}
|
||||
|
||||
.versions-entry-empty-icon {
|
||||
background: var(--color-background-tertiary);
|
||||
border-radius: 50%;
|
||||
padding: $s-8;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.version-save-version {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-foreground-secondary);
|
||||
font-size: $fs-12;
|
||||
padding: $s-16 0 $s-16 $s-16;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.version-save-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.versions-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $s-6;
|
||||
}
|
||||
|
||||
.version-entry {
|
||||
border: 1px solid transparent;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:hover .version-entry-options {
|
||||
visibility: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.version-entry {
|
||||
display: flex;
|
||||
padding: $s-4 $s-4 $s-4 $s-16;
|
||||
gap: $s-8;
|
||||
border-radius: 8px;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.version-entry.is-autosave {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
padding-left: $s-48;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.version-entry-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.version-entry-avatar {
|
||||
border-radius: 50%;
|
||||
width: $s-24;
|
||||
height: $s-24;
|
||||
}
|
||||
|
||||
.version-entry-data {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.version-entry-name {
|
||||
color: var(--color-foreground-primary);
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.version-entry-name-edit {
|
||||
font-size: $fs-12;
|
||||
color: var(--color-foreground-primary);
|
||||
background: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-bottom: 1px solid var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.version-entry-time {
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.version-entry-options {
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
padding: 0;
|
||||
height: $s-40;
|
||||
width: $s-32;
|
||||
}
|
||||
|
||||
.version-options-dropdown {
|
||||
@extend .dropdown-wrapper;
|
||||
position: absolute;
|
||||
width: fit-content;
|
||||
max-width: $s-200;
|
||||
right: 0;
|
||||
left: unset;
|
||||
.menu-option {
|
||||
@extend .dropdown-element-base;
|
||||
}
|
||||
}
|
||||
|
||||
.version-entry-snapshots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-6;
|
||||
color: var(--color-foreground-secondary);
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
.icon-clock {
|
||||
stroke: var(--color-accent-warning);
|
||||
}
|
||||
|
||||
.icon-arrow {
|
||||
stroke: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-accent-primary);
|
||||
.icon-arrow {
|
||||
stroke: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.is-expanded & .icon-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.version-snapshot-list {
|
||||
display: none;
|
||||
margin-top: $s-8;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
.version-entry.is-expanded & {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.version-snapshot-entry-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&:hover .version-snapshot-menu-btn {
|
||||
visibility: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.version-snapshot-entry {
|
||||
font-size: $fs-12;
|
||||
color: var(--color-foreground-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-accent-primary);
|
||||
:global(.icon-pin) {
|
||||
visibility: initial;
|
||||
fill: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-snapshot-menu-btn {
|
||||
visibility: hidden;
|
||||
}
|
|
@ -429,12 +429,12 @@
|
|||
|
||||
params {:id (:id file)
|
||||
:revn (:revn file)
|
||||
:vern (:vern file)
|
||||
:session-id sid
|
||||
:changes changes
|
||||
:features features
|
||||
:skip-validate true}]
|
||||
|
||||
|
||||
(->> (rp/cmd! :update-file params)
|
||||
(rx/subs! (fn [_]
|
||||
(when reload?
|
||||
|
|
|
@ -1978,6 +1978,9 @@ msgstr "Remove member"
|
|||
msgid "labels.rename"
|
||||
msgstr "Rename"
|
||||
|
||||
msgid "labels.restore"
|
||||
msgstr "Restore"
|
||||
|
||||
#: src/app/main/ui/dashboard/team_form.cljs:99
|
||||
msgid "labels.rename-team"
|
||||
msgstr "Rename team"
|
||||
|
@ -6351,3 +6354,48 @@ msgstr "Update"
|
|||
#, unused
|
||||
msgid "workspace.viewport.click-to-close-path"
|
||||
msgstr "Click to close the path"
|
||||
|
||||
msgid "workspace.versions.button.save"
|
||||
msgstr "Save version"
|
||||
|
||||
msgid "workspace.versions.button.pin"
|
||||
msgstr "Pin version"
|
||||
|
||||
msgid "workspace.versions.button.restore"
|
||||
msgstr "Restore version"
|
||||
|
||||
msgid "workspace.versions.empty"
|
||||
msgstr "There are no versions yet"
|
||||
|
||||
msgid "workspace.versions.autosaved.version"
|
||||
msgstr "Autosaved %s"
|
||||
|
||||
msgid "workspace.versions.autosaved.entry"
|
||||
msgstr "%s autosave versions"
|
||||
|
||||
msgid "workspace.versions.loading"
|
||||
msgstr "Loading..."
|
||||
|
||||
msgid "workspace.versions.filter.label"
|
||||
msgstr "Versions filter"
|
||||
|
||||
msgid "workspace.versions.filter.all"
|
||||
msgstr "All versions"
|
||||
|
||||
msgid "workspace.versions.filter.mine"
|
||||
msgstr "My versions"
|
||||
|
||||
msgid "workspace.versions.filter.user"
|
||||
msgstr "%s's versions"
|
||||
|
||||
msgid "workspace.versions.restore-warning"
|
||||
msgstr "Do you want to restore this version?"
|
||||
|
||||
msgid "workspace.versions.snapshot-menu"
|
||||
msgstr "Open snapshot menu"
|
||||
|
||||
msgid "workspace.versions.version-menu"
|
||||
msgstr "Open version menu"
|
||||
|
||||
msgid "workspace.versions.expand-snapshot"
|
||||
msgstr "Expand snapshots"
|
||||
|
|
|
@ -1970,6 +1970,9 @@ msgstr "Eliminar integrante"
|
|||
msgid "labels.rename"
|
||||
msgstr "Renombrar"
|
||||
|
||||
msgid "labels.restore"
|
||||
msgstr "Restaurar"
|
||||
|
||||
#: src/app/main/ui/dashboard/team_form.cljs:99
|
||||
msgid "labels.rename-team"
|
||||
msgstr "Renombra el equipo"
|
||||
|
@ -6332,3 +6335,48 @@ msgstr "Pulsar para cerrar la ruta"
|
|||
|
||||
msgid "errors.maximum-invitations-by-request-reached"
|
||||
msgstr "Se ha alcanzado el número máximo (%s) de correos electrónicos que se pueden invitar en una sola solicitud"
|
||||
|
||||
msgid "workspace.versions.button.save"
|
||||
msgstr "Guardar versión"
|
||||
|
||||
msgid "workspace.versions.button.pin"
|
||||
msgstr "Fijar versión"
|
||||
|
||||
msgid "workspace.versions.button.restore"
|
||||
msgstr "Restaurar versión"
|
||||
|
||||
msgid "workspace.versions.empty"
|
||||
msgstr "No hay versiones aún"
|
||||
|
||||
msgid "workspace.versions.autosaved.version"
|
||||
msgstr "Autoguardado %s"
|
||||
|
||||
msgid "workspace.versions.autosaved.entry"
|
||||
msgstr "%s versiones de autoguardado"
|
||||
|
||||
msgid "workspace.versions.loading"
|
||||
msgstr "Cargando..."
|
||||
|
||||
msgid "workspace.versions.filter.label"
|
||||
msgstr "Filtro de versiones"
|
||||
|
||||
msgid "workspace.versions.filter.all"
|
||||
msgstr "Todas las versiones"
|
||||
|
||||
msgid "workspace.versions.filter.mine"
|
||||
msgstr "Mis versiones"
|
||||
|
||||
msgid "workspace.versions.filter.user"
|
||||
msgstr "Versiones de %s"
|
||||
|
||||
msgid "workspace.versions.restore-warning"
|
||||
msgstr "¿Quieres restaurar esta versión?"
|
||||
|
||||
msgid "workspace.versions.snapshot-menu"
|
||||
msgstr "Abrir menu de versiones"
|
||||
|
||||
msgid "workspace.versions.version-menu"
|
||||
msgstr "Abrir menu de versiones"
|
||||
|
||||
msgid "workspace.versions.expand-snapshot"
|
||||
msgstr "Expandir versiones"
|
||||
|
|
Loading…
Reference in a new issue