File history versions management

This commit is contained in:
alonso.torres 2024-10-15 12:11:32 +02:00
parent fa4f2aa5cc
commit ecb7f0a2f6
33 changed files with 1100 additions and 102 deletions

View file

@ -13,6 +13,7 @@
### :sparkles: New features ### :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) - 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 ### :bug: Bugs fixed

View file

@ -89,6 +89,10 @@
(dissoc ::s/problems ::s/value ::s/spec ::sm/explain) (dissoc ::s/problems ::s/value ::s/spec ::sm/explain)
(cond-> explain (assoc :explain explain)))}) (cond-> explain (assoc :explain explain)))})
(= code :vern-conflict)
{::yres/status 409 ;; 409 - Conflict
::yres/body data}
(= code :request-body-too-large) (= code :request-body-too-large)
{::yres/status 413 ::yres/body data} {::yres/status 413 ::yres/body data}

View file

@ -415,7 +415,13 @@
:fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")} :fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")}
{:name "0131-mod-webhook-table" {: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! (defn apply-migrations!
[pool name migrations] [pool name migrations]

View file

@ -0,0 +1,2 @@
ALTER TABLE file_change
ADD COLUMN created_by text NOT NULL DEFAULT 'system';

View file

@ -0,0 +1,2 @@
ALTER TABLE file
ADD COLUMN vern int NOT NULL DEFAULT 0;

View file

@ -182,6 +182,7 @@
[:comment-thread-seqn [::sm/int {:min 0}]] [:comment-thread-seqn [::sm/int {:min 0}]]
[:name [:string {:max 250}]] [:name [:string {:max 250}]]
[:revn [::sm/int {:min 0}]] [:revn [::sm/int {:min 0}]]
[:vern [::sm/int {:min 0}]]
[:modified-at ::dt/instant] [:modified-at ::dt/instant]
[:is-shared ::sm/boolean] [:is-shared ::sm/boolean]
[:project-id ::sm/uuid] [:project-id ::sm/uuid]
@ -270,7 +271,7 @@
(defn get-minimal-file (defn get-minimal-file
[cfg id & {:as opts}] [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))) (db/get cfg :file {:id id} opts)))
(defn- get-minimal-file-with-perms (defn- get-minimal-file-with-perms
@ -280,8 +281,8 @@
(assoc mfile :permissions perms))) (assoc mfile :permissions perms)))
(defn get-file-etag (defn get-file-etag
[{:keys [::rpc/profile-id]} {:keys [modified-at revn permissions]}] [{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}]
(str profile-id "/" revn "/" (str profile-id "/" revn "/" vern "/"
(dt/format-instant modified-at :iso) (dt/format-instant modified-at :iso)
"/" "/"
(uri/map->query-string permissions))) (uri/map->query-string permissions)))
@ -371,6 +372,7 @@
f.modified_at, f.modified_at,
f.name, f.name,
f.revn, f.revn,
f.vern,
f.is_shared, f.is_shared,
ft.media_id ft.media_id
from file as f from file as f
@ -526,6 +528,7 @@
(def ^:private sql:team-shared-files (def ^:private sql:team-shared-files
"select f.id, "select f.id,
f.revn, f.revn,
f.vern,
f.data, f.data,
f.project_id, f.project_id,
f.created_at, f.created_at,
@ -609,6 +612,7 @@
l.deleted_at, l.deleted_at,
l.name, l.name,
l.revn, l.revn,
l.vern,
l.synced_at l.synced_at
FROM libs AS l FROM libs AS l
WHERE l.deleted_at IS NULL OR l.deleted_at > now();") WHERE l.deleted_at IS NULL OR l.deleted_at > now();")
@ -670,6 +674,7 @@
"with recent_files as ( "with recent_files as (
select f.id, select f.id,
f.revn, f.revn,
f.vern,
f.project_id, f.project_id,
f.created_at, f.created_at,
f.modified_at, f.modified_at,

View file

@ -10,14 +10,13 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.db.sql :as-alias sql] [app.db.sql :as-alias sql]
[app.features.fdata :as feat.fdata] [app.features.fdata :as feat.fdata]
[app.main :as-alias main] [app.main :as-alias main]
[app.msgbus :as mbus]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.commands.files :as files] [app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.storage :as sto] [app.storage :as sto]
[app.util.blob :as blob] [app.util.blob :as blob]
@ -26,22 +25,12 @@
[app.util.time :as dt] [app.util.time :as dt]
[cuerdas.core :as str])) [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 (def sql:get-file-snapshots
"SELECT id, label, revn, created_at "SELECT id, label, revn, created_at, created_by, profile_id
FROM file_change FROM file_change
WHERE file_id = ? WHERE file_id = ?
AND created_at < ? AND created_at < ?
AND label IS NOT NULL AND data IS NOT NULL
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ?") LIMIT ?")
@ -50,25 +39,23 @@
:or {limit Long/MAX_VALUE}}] :or {limit Long/MAX_VALUE}}]
(let [start-at (or start-at (dt/now)) (let [start-at (or start-at (dt/now))
limit (min limit 20)] limit (min limit 20)]
(->> (db/exec! conn [sql:get-file-snapshots file-id start-at limit]) (db/exec! conn [sql:get-file-snapshots file-id start-at limit])))
(mapv (fn [row]
(update row :created-at dt/format-instant :rfc1123))))))
(def ^:private schema:get-file-snapshots (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 (sv/defmethod ::get-file-snapshots
{::doc/added "1.20" {::doc/added "1.20"
::doc/skip true
::sm/params schema:get-file-snapshots} ::sm/params schema:get-file-snapshots}
[cfg {:keys [::rpc/profile-id] :as params}] [cfg params]
(check-authorized! cfg profile-id)
(db/run! cfg get-file-snapshots params)) (db/run! cfg get-file-snapshots params))
(defn restore-file-snapshot! (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}) (let [storage (sto/resolve cfg {::db/reuse-conn true})
file (files/get-minimal-file conn file-id {::db/for-update 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 snapshot (db/get* conn :file-change
{:file-id file-id {:file-id file-id
:id id} :id id}
@ -103,6 +90,7 @@
(db/update! conn :file (db/update! conn :file
{:data (:data snapshot) {:data (:data snapshot)
:revn (inc (:revn file)) :revn (inc (:revn file))
:vern vern
:version (:version snapshot) :version (:version snapshot)
:data-backend nil :data-backend nil
:data-ref-id nil :data-ref-id nil
@ -126,38 +114,28 @@
(doseq [media-id (into #{} (keep :media-id) res)] (doseq [media-id (into #{} (keep :media-id) res)]
(sto/touch-object! storage media-id))) (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) {:id (:id snapshot)
:label (:label 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 (def ^:private
schema:restore-file-snapshot schema:restore-file-snapshot
[:and [:and
[:map [:map
[:file-id ::sm/uuid] [:file-id ::sm/uuid]
[:id {:optional true} ::sm/uuid] [:id {:optional true} ::sm/uuid]]
[:label {:optional true} :string]]
[::sm/contains-any #{:id :label}]]) [::sm/contains-any #{:id :label}]])
(sv/defmethod ::restore-file-snapshot (sv/defmethod ::restore-file-snapshot
{::doc/added "1.20" {::doc/added "1.20"
::doc/skip true
::sm/params schema:restore-file-snapshot} ::sm/params schema:restore-file-snapshot}
[cfg {:keys [::rpc/profile-id file-id id label] :as params}] [cfg params]
(check-authorized! cfg profile-id) (db/tx-run! cfg restore-file-snapshot! params))
(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)))))
(defn- get-file (defn- get-file
[cfg file-id] [cfg file-id]
@ -170,29 +148,7 @@
(update :data feat.fdata/process-objects (partial into {})) (update :data feat.fdata/process-objects (partial into {}))
(update :data blob/encode))))) (update :data blob/encode)))))
(defn take-file-snapshot! (defn- generate-snapshot-label
[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
[] []
(let [ts (-> (dt/now) (let [ts (-> (dt/now)
(dt/format-instant) (dt/format-instant)
@ -200,17 +156,92 @@
(str/rtrim "Z"))] (str/rtrim "Z"))]
(str "snapshot-" ts))) (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 (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 (sv/defmethod ::take-file-snapshot
{::doc/added "1.20" {::doc/added "1.20"
::doc/skip true
::sm/params schema:take-file-snapshot} ::sm/params schema:take-file-snapshot}
[cfg {:keys [::rpc/profile-id] :as params}] [cfg params]
(check-authorized! cfg profile-id) (db/tx-run! cfg take-file-snapshot! params))
(db/tx-run! cfg (fn [cfg]
(let [params (update params :label (fn [label] (def ^:private schema:update-file-snapshot
(or label (generate-snapshot-label))))] [:map {:title "update-file-snapshot"}
(take-file-snapshot! cfg params))))) [: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))

View file

@ -60,6 +60,7 @@
[:id ::sm/uuid] [:id ::sm/uuid]
[:session-id ::sm/uuid] [:session-id ::sm/uuid]
[:revn {:min 0} ::sm/int] [:revn {:min 0} ::sm/int]
[:vern {:min 0} ::sm/int]
[:features {:optional true} ::cfeat/features] [:features {:optional true} ::cfeat/features]
[:changes {:optional true} [:vector ::cpc/change]] [:changes {:optional true} [:vector ::cpc/change]]
[:changes-with-metadata {:optional true} [:changes-with-metadata {:optional true}
@ -157,6 +158,14 @@
tpoint (dt/tpoint)] 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) (when (> (:revn params)
(:revn file)) (:revn file))
(ex/raise :type :validation (ex/raise :type :validation
@ -455,7 +464,7 @@
"SELECT fch.id, fch.created_at "SELECT fch.id, fch.created_at
FROM file_change AS fch FROM file_change AS fch
WHERE fch.file_id = ? WHERE fch.file_id = ?
AND fch.label LIKE 'internal/%' AND fch.created_by = 'system'
ORDER BY fch.created_at DESC ORDER BY fch.created_at DESC
LIMIT ?") LIMIT ?")
@ -465,7 +474,7 @@
"UPDATE file_change "UPDATE file_change
SET label = NULL SET label = NULL
WHERE file_id = ? WHERE file_id = ?
AND label LIKE 'internal/%' AND created_by LIKE 'system'
AND created_at < ?") AND created_at < ?")
(defn- delete-old-snapshots! (defn- delete-old-snapshots!
@ -502,6 +511,7 @@
:file-id (:id file) :file-id (:id file)
:session-id (:session-id params) :session-id (:session-id params)
:revn (:revn file) :revn (:revn file)
:vern (:vern file)
:changes changes}) :changes changes})
(when (and (:is-shared file) (seq lchanges)) (when (and (:is-shared file) (seq lchanges))

View file

@ -336,8 +336,7 @@
(defn take-team-snapshot! (defn take-team-snapshot!
[team-id & {:keys [label rollback?] :or {rollback? true}}] [team-id & {:keys [label rollback?] :or {rollback? true}}]
(let [team-id (h/parse-uuid team-id) (let [team-id (h/parse-uuid team-id)]
label (or label (fsnap/generate-snapshot-label))]
(-> (assoc main/system ::db/rollback rollback?) (-> (assoc main/system ::db/rollback rollback?)
(db/tx-run! h/take-team-snapshot! team-id label)))) (db/tx-run! h/take-team-snapshot! team-id label))))

View file

@ -36,6 +36,7 @@
:id file-id :id file-id
:session-id (uuid/random) :session-id (uuid/random)
:revn revn :revn revn
:vern 0
:features cfeat/supported-features :features cfeat/supported-features
:changes changes} :changes changes}
out (th/command! params)] out (th/command! params)]
@ -55,6 +56,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-page [{:type :add-page
:name "test 1" :name "test 1"
@ -67,6 +69,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-obj [{:type :add-obj
:page-id page-id-1 :page-id page-id-1

View file

@ -312,6 +312,7 @@
(#'files.update/update-file* system (#'files.update/update-file* system
{:id file-id {:id file-id
:revn revn :revn revn
:vern 0
:file file :file file
:features (:features file) :features (:features file)
:changes changes :changes changes
@ -327,6 +328,7 @@
:id file-id :id file-id
:session-id (uuid/random) :session-id (uuid/random)
:revn revn :revn revn
:vern 0
:features features :features features
:changes changes} :changes changes}
out (command! params)] out (command! params)]

View file

@ -32,6 +32,7 @@
:id file-id :id file-id
:session-id (uuid/random) :session-id (uuid/random)
:revn revn :revn revn
:vern 0
:features cfeat/supported-features :features cfeat/supported-features
:changes changes} :changes changes}
out (th/command! params)] out (th/command! params)]
@ -147,6 +148,7 @@
:id file-id :id file-id
:session-id (uuid/random) :session-id (uuid/random)
:revn revn :revn revn
:vern 0
:features cfeat/supported-features :features cfeat/supported-features
:changes changes} :changes changes}
out (th/command! params)] out (th/command! params)]
@ -174,6 +176,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-page [{:type :add-page
:name "test" :name "test"
@ -203,6 +206,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-obj [{:type :add-obj
:page-id page-id :page-id page-id
@ -279,6 +283,7 @@
:id file-id :id file-id
:session-id (uuid/random) :session-id (uuid/random)
:revn revn :revn revn
:vern 0
:features cfeat/supported-features :features cfeat/supported-features
:changes changes} :changes changes}
out (th/command! params)] out (th/command! params)]
@ -305,6 +310,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-obj [{:type :add-obj
:page-id page-id :page-id page-id
@ -367,6 +373,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes [{:type :del-obj :changes [{:type :del-obj
:page-id (first (get-in file [:data :pages])) :page-id (first (get-in file [:data :pages]))
:id shid}]) :id shid}])
@ -418,6 +425,7 @@
:id file-id :id file-id
:session-id (uuid/random) :session-id (uuid/random)
:revn revn :revn revn
:vern 0
:components-v2 true :components-v2 true
:changes changes} :changes changes}
out (th/command! params)] out (th/command! params)]
@ -452,6 +460,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-obj [{:type :add-obj
:page-id page-id :page-id page-id
@ -528,6 +537,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes [{:type :del-obj :changes [{:type :del-obj
:page-id (first (get-in file [:data :pages])) :page-id (first (get-in file [:data :pages]))
:id s-shid} :id s-shid}
@ -622,6 +632,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-obj [{:type :add-obj
:page-id page-id :page-id page-id
@ -688,6 +699,7 @@
:file-id file-id :file-id file-id
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes [{:type :del-obj :changes [{:type :del-obj
:page-id page-id :page-id page-id
:id frame-id-2}]) :id frame-id-2}])
@ -721,6 +733,7 @@
:file-id file-id :file-id file-id
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes [{:type :del-obj :changes [{:type :del-obj
:page-id page-id :page-id page-id
:id frame-id-1}]) :id frame-id-1}])
@ -978,6 +991,7 @@
(th/update-file* {:file-id (:id file) (th/update-file* {:file-id (:id file)
:profile-id (:id prof) :profile-id (:id prof)
:revn 0 :revn 0
:vern 0
:components-v2 true :components-v2 true
:changes changes}) :changes changes})
@ -1178,6 +1192,7 @@
file (th/create-file* 1 {:profile-id (:id prof) file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof) :project-id (:default-project-id prof)
:revn 2 :revn 2
:vern 0
:is-shared false})] :is-shared false})]
(t/testing "create a file thumbnail" (t/testing "create a file thumbnail"
@ -1286,6 +1301,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-page [{:type :add-page
:name "test" :name "test"

View file

@ -42,6 +42,7 @@
:file-id (:id file) :file-id (:id file)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-obj [{:type :add-obj
:page-id page-id :page-id page-id
@ -253,7 +254,8 @@
file (th/create-file* 1 {:profile-id (:id profile) file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile) :project-id (:default-project-id profile)
:is-shared false :is-shared false
:revn 3}) :revn 3
:vern 0})
data1 {::th/type :create-file-thumbnail data1 {::th/type :create-file-thumbnail
::rpc/profile-id (:id profile) ::rpc/profile-id (:id profile)

View file

@ -30,6 +30,7 @@
:id file-id :id file-id
:session-id (uuid/random) :session-id (uuid/random)
:revn revn :revn revn
:vern 0
:features cfeat/supported-features :features cfeat/supported-features
:changes changes} :changes changes}
out (th/command! params)] out (th/command! params)]
@ -65,6 +66,7 @@
:file-id (:id file1) :file-id (:id file1)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-media [{:type :add-media
:object mobj}]) :object mobj}])
@ -195,6 +197,7 @@
:file-id (:id file1) :file-id (:id file1)
:profile-id (:id profile) :profile-id (:id profile)
:revn 0 :revn 0
:vern 0
:changes :changes
[{:type :add-media [{:type :add-media
:object mobj}]) :object mobj}])

View file

@ -134,6 +134,7 @@
:project-id project-id :project-id project-id
:name name :name name
:revn revn :revn revn
:vern 0
:is-shared is-shared :is-shared is-shared
:version version :version version
:data data :data data

View 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

View file

@ -106,7 +106,7 @@
(defn commit (defn commit
"Create a commit event instance" "Create a commit event instance"
[{:keys [commit-id redo-changes undo-changes origin save-undo? features [{: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! (dm/assert!
"expect valid vector of changes for redo-changes" "expect valid vector of changes for redo-changes"
@ -126,6 +126,7 @@
:features features :features features
:file-id file-id :file-id file-id
:file-revn file-revn :file-revn file-revn
:file-vern file-vern
:changes redo-changes :changes redo-changes
:redo-changes redo-changes :redo-changes redo-changes
:undo-changes undo-changes :undo-changes undo-changes
@ -160,6 +161,13 @@
(:revn file) (:revn file)
(dm/get-in state [:workspace-libraries file-id :revn])))) (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 (defn commit-changes
"Schedules a list of changes to execute now, and add the corresponding undo changes to "Schedules a list of changes to execute now, and add the corresponding undo changes to
the undo stack. the undo stack.
@ -194,6 +202,7 @@
(assoc :save-undo? save-undo?) (assoc :save-undo? save-undo?)
(assoc :file-id file-id) (assoc :file-id file-id)
(assoc :file-revn (resolve-file-revn state 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 :undo-changes uchg)
(assoc :redo-changes rchg) (assoc :redo-changes rchg)
(commit)))))))) (commit))))))))

View file

@ -108,11 +108,12 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(log/dbg :hint "persist-commit" :commit-id (dm/str commit-id)) (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) (let [sid (:session-id state)
revn (max file-revn (get @revn-data file-id 0)) revn (max file-revn (get @revn-data file-id 0))
params {:id file-id params {:id file-id
:revn revn :revn revn
:vern file-vern
:session-id sid :session-id sid
:origin (:origin commit) :origin (:origin commit)
:created-at (:created-at commit) :created-at (:created-at commit)

View file

@ -70,6 +70,7 @@
[app.main.data.workspace.undo :as dwu] [app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.viewport :as dwv] [app.main.data.workspace.viewport :as dwv]
[app.main.data.workspace.zoom :as dwz] [app.main.data.workspace.zoom :as dwz]
[app.main.errors]
[app.main.features :as features] [app.main.features :as features]
[app.main.features.pointer-map :as fpmap] [app.main.features.pointer-map :as fpmap]
[app.main.repo :as rp] [app.main.repo :as rp]
@ -378,6 +379,19 @@
(let [name (dm/str "workspace-" file-id)] (let [name (dm/str "workspace-" file-id)]
(unchecked-set ug/global "name" name))))) (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 (defn finalize-file
[_project-id file-id] [_project-id file-id]
(ptk/reify ::finalize-file (ptk/reify ::finalize-file

View file

@ -29,12 +29,16 @@
[clojure.set :as set] [clojure.set :as set]
[potok.v2.core :as ptk])) [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 ;; FIXME: this ns should be renamed to something different
(declare process-message) (declare process-message)
(declare handle-presence) (declare handle-presence)
(declare handle-pointer-update) (declare handle-pointer-update)
(declare handle-file-change) (declare handle-file-change)
(declare handle-file-restore)
(declare handle-library-change) (declare handle-library-change)
(declare handle-pointer-send) (declare handle-pointer-send)
(declare handle-export-update) (declare handle-export-update)
@ -124,6 +128,7 @@
:disconnect (handle-presence msg) :disconnect (handle-presence msg)
:pointer-update (handle-pointer-update msg) :pointer-update (handle-pointer-update msg)
:file-change (handle-file-change msg) :file-change (handle-file-change msg)
:file-restore (handle-file-restore msg)
:library-change (handle-library-change msg) :library-change (handle-library-change msg)
:notification (dc/handle-notification msg) :notification (dc/handle-notification msg)
:team-role-change (handle-change-team-role msg) :team-role-change (handle-change-team-role msg)
@ -229,13 +234,14 @@
[:file-id ::sm/uuid] [:file-id ::sm/uuid]
[:session-id ::sm/uuid] [:session-id ::sm/uuid]
[:revn :int] [:revn :int]
[:vern :int]
[:changes ::cpc/changes]]) [:changes ::cpc/changes]])
(def ^:private check-file-change-params! (def ^:private check-file-change-params!
(sm/check-fn schema:handle-file-change)) (sm/check-fn schema:handle-file-change))
(defn handle-file-change (defn handle-file-change
[{:keys [file-id changes revn] :as msg}] [{:keys [file-id changes revn vern] :as msg}]
(dm/assert! (dm/assert!
"expected valid parameters" "expected valid parameters"
@ -250,13 +256,41 @@
;; The commit event is responsible to apply the data localy ;; The commit event is responsible to apply the data localy
;; and update the persistence internal state with the updated ;; and update the persistence internal state with the updated
;; file-revn ;; file-revn
(rx/of (dch/commit {:file-id file-id (rx/of (dch/commit {:file-id file-id
:file-revn revn :file-revn revn
:file-vern vern
:save-undo? false :save-undo? false
:source :remote :source :remote
:redo-changes (vec changes) :redo-changes (vec changes)
:undo-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 (def ^:private schema:handle-library-change
[:map {:title "handle-library-change"} [:map {:title "handle-library-change"}
[:type :keyword] [:type :keyword]

View 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))))))))

View file

@ -21,6 +21,9 @@
[cuerdas.core :as str] [cuerdas.core :as str]
[potok.v2.core :as ptk])) [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! (defn- print-data!
[data] [data]
(-> data (-> data
@ -137,6 +140,9 @@
:level :error :level :error
:timeout 3000}))) :timeout 3000})))
(= code :vern-conflict)
(st/emit! (reload-file))
:else :else
(st/async-emit! (rt/assign-exception error)))) (st/async-emit! (rt/assign-exception error))))

View file

@ -591,6 +591,9 @@
(def current-file-id (def current-file-id
(l/derived :current-file-id st/state)) (l/derived :current-file-id st/state))
(def current-project-id
(l/derived :current-project-id st/state))
(def workspace-preview-blend (def workspace-preview-blend
(l/derived :workspace-preview-blend st/state)) (l/derived :workspace-preview-blend st/state))
@ -604,4 +607,4 @@
(l/derived :updating-library st/state)) (l/derived :updating-library st/state))
(def persistence-state (def persistence-state
(l/derived (comp :status :workspace-persistence) st/state)) (l/derived (comp :status :persistence) st/state))

View file

@ -88,6 +88,7 @@
(def ^:icon-id character-z "character-z") (def ^:icon-id character-z "character-z")
(def ^:icon-id clip-content "clip-content") (def ^:icon-id clip-content "clip-content")
(def ^:icon-id clipboard "clipboard") (def ^:icon-id clipboard "clipboard")
(def ^:icon-id clock "clock")
(def ^:icon-id close-small "close-small") (def ^:icon-id close-small "close-small")
(def ^:icon-id close "close") (def ^:icon-id close "close")
(def ^:icon-id code "code") (def ^:icon-id code "code")

View file

@ -72,6 +72,7 @@
(def ^:icon bug (icon-xref :bug)) (def ^:icon bug (icon-xref :bug))
(def ^:icon clip-content (icon-xref :clip-content)) (def ^:icon clip-content (icon-xref :clip-content))
(def ^:icon clipboard (icon-xref :clipboard)) (def ^:icon clipboard (icon-xref :clipboard))
(def ^:icon clock (icon-xref :clock))
(def ^:icon close-small (icon-xref :close-small)) (def ^:icon close-small (icon-xref :close-small))
(def ^:icon close (icon-xref :close)) (def ^:icon close (icon-xref :close))
(def ^:icon code (icon-xref :code)) (def ^:icon code (icon-xref :code))

View file

@ -26,6 +26,7 @@
[app.main.ui.workspace.sidebar.options :refer [options-toolbox]] [app.main.ui.workspace.sidebar.options :refer [options-toolbox]]
[app.main.ui.workspace.sidebar.shortcuts :refer [shortcuts-container]] [app.main.ui.workspace.sidebar.shortcuts :refer [shortcuts-container]]
[app.main.ui.workspace.sidebar.sitemap :refer [sitemap]] [app.main.ui.workspace.sidebar.sitemap :refer [sitemap]]
[app.main.ui.workspace.sidebar.versions :refer [versions-toolbox]]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
@ -181,7 +182,17 @@
props props
(mf/spread props (mf/spread props
:on-change-section handle-change-section :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} [:& (mf/provider muc/sidebar) {:value :right}
[:aside {:class (stl/css-case :right-settings-bar true [:aside {:class (stl/css-case :right-settings-bar true
@ -208,7 +219,15 @@
[:& comments-sidebar] [:& comments-sidebar]
(true? is-history?) (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 :else
[:> options-toolbox props])]]])) [:> options-toolbox props])]]]))

View file

@ -109,3 +109,8 @@ $width-settings-bar-max: $s-500;
--collapse-icon-color: var(--color-foreground-primary); --collapse-icon-color: var(--color-foreground-primary);
} }
} }
.versions-tab {
width: 100%;
overflow: hidden;
}

View file

@ -9,12 +9,9 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [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.data.workspace.undo :as dwu]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.icons :as i] [app.main.ui.icons :as i]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :refer [tr] :as i18n] [app.util.i18n :refer [tr] :as i18n]
@ -326,18 +323,8 @@
[] []
(let [objects (mf/deref refs/workspace-page-objects) (let [objects (mf/deref refs/workspace-page-objects)
{:keys [items index]} (mf/deref workspace-undo) {:keys [items index]} (mf/deref workspace-undo)
entries (parse-entries items objects) 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"))))]
[:div {:class (stl/css :history-toolbox)} [: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) (if (empty? entries)
[:div {:class (stl/css :history-entry-empty)} [:div {:class (stl/css :history-entry-empty)}
[:div {:class (stl/css :history-entry-empty-icon)} i/history] [:div {:class (stl/css :history-entry-empty-icon)} i/history]

View 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))])])]))

View 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;
}

View file

@ -429,12 +429,12 @@
params {:id (:id file) params {:id (:id file)
:revn (:revn file) :revn (:revn file)
:vern (:vern file)
:session-id sid :session-id sid
:changes changes :changes changes
:features features :features features
:skip-validate true}] :skip-validate true}]
(->> (rp/cmd! :update-file params) (->> (rp/cmd! :update-file params)
(rx/subs! (fn [_] (rx/subs! (fn [_]
(when reload? (when reload?

View file

@ -1978,6 +1978,9 @@ msgstr "Remove member"
msgid "labels.rename" msgid "labels.rename"
msgstr "Rename" msgstr "Rename"
msgid "labels.restore"
msgstr "Restore"
#: src/app/main/ui/dashboard/team_form.cljs:99 #: src/app/main/ui/dashboard/team_form.cljs:99
msgid "labels.rename-team" msgid "labels.rename-team"
msgstr "Rename team" msgstr "Rename team"
@ -6351,3 +6354,48 @@ msgstr "Update"
#, unused #, unused
msgid "workspace.viewport.click-to-close-path" msgid "workspace.viewport.click-to-close-path"
msgstr "Click to close the 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"

View file

@ -1970,6 +1970,9 @@ msgstr "Eliminar integrante"
msgid "labels.rename" msgid "labels.rename"
msgstr "Renombrar" msgstr "Renombrar"
msgid "labels.restore"
msgstr "Restaurar"
#: src/app/main/ui/dashboard/team_form.cljs:99 #: src/app/main/ui/dashboard/team_form.cljs:99
msgid "labels.rename-team" msgid "labels.rename-team"
msgstr "Renombra el equipo" msgstr "Renombra el equipo"
@ -6332,3 +6335,48 @@ msgstr "Pulsar para cerrar la ruta"
msgid "errors.maximum-invitations-by-request-reached" 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" 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"