diff --git a/CHANGES.md b/CHANGES.md index 5b4ab291b..62f13c132 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index 45fe6690d..aa82cb354 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -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} diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 0f24a6d77..e09d45752 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -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] diff --git a/backend/src/app/migrations/sql/0132-mod-file-change-table.sql b/backend/src/app/migrations/sql/0132-mod-file-change-table.sql new file mode 100644 index 000000000..349d85ab7 --- /dev/null +++ b/backend/src/app/migrations/sql/0132-mod-file-change-table.sql @@ -0,0 +1,2 @@ +ALTER TABLE file_change + ADD COLUMN created_by text NOT NULL DEFAULT 'system'; diff --git a/backend/src/app/migrations/sql/0133-mod-file-table.sql b/backend/src/app/migrations/sql/0133-mod-file-table.sql new file mode 100644 index 000000000..81695d292 --- /dev/null +++ b/backend/src/app/migrations/sql/0133-mod-file-table.sql @@ -0,0 +1,2 @@ +ALTER TABLE file + ADD COLUMN vern int NOT NULL DEFAULT 0; diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index cdf4a0fb9..4856d5714 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -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, diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 2fdb262a0..6b42e2530 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -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)) + diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index bd98b7071..dd41f84c7 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -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)) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 97294927d..404ab6c5e 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -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)))) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 25bde4606..f50c5cf2e 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -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 diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 8e83c4474..0095e2363 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -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)] diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 9a072eaa8..d6678be29 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -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" diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 2ceffbddf..e5a44fbdb 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -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) diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index 2b3170942..998b7405b 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -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}]) diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index bde826b57..8dab26240 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -134,6 +134,7 @@ :project-id project-id :name name :revn revn + :vern 0 :is-shared is-shared :version version :data data diff --git a/frontend/resources/images/icons/clock.svg b/frontend/resources/images/icons/clock.svg new file mode 100644 index 000000000..c45c62272 --- /dev/null +++ b/frontend/resources/images/icons/clock.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index e10063fb0..00f990763 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -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)))))))) diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 7741b6103..12112ca91 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -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) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 7ac3535bf..4fb840c68 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -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 diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index d8a4fdb4e..8bc96033a 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -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] diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs new file mode 100644 index 000000000..fa5a9bb98 --- /dev/null +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -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)))))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 61ff775a3..7a4f2e2e4 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -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)))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 5b6c2bdea..f0a8db187 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 50545e1e2..fa1485da4 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -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") diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index ae0e8d250..c73c7d1bd 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index 060544b2e..74fa94f8c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -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])]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar.scss index 1e0a13829..64ca985a4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar.scss @@ -109,3 +109,8 @@ $width-settings-bar-max: $s-500; --collapse-icon-color: var(--color-foreground-primary); } } + +.versions-tab { + width: 100%; + overflow: hidden; +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.cljs b/frontend/src/app/main/ui/workspace/sidebar/history.cljs index 463ae4c13..618e397b0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/history.cljs @@ -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] diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs new file mode 100644 index 000000000..fd0108c4c --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs @@ -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))])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.scss b/frontend/src/app/main/ui/workspace/sidebar/versions.scss new file mode 100644 index 000000000..703f8d3be --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.scss @@ -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; +} diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index c7c06dfc2..92642ddc0 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -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? diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 90fd0e5dd..8a26dcf90 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index a87f9f195..893b3a263 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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"