diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index ff9b9a4c6f..541e030f3c 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -514,7 +514,10 @@ :fn (mg/resource "app/migrations/sql/0155-drop-http-session-table.sql")} {:name "0156-add-storage-object-json-dedup-index" - :fn (mg/resource "app/migrations/sql/0156-add-storage-object-json-dedup-index.sql")}]) + :fn (mg/resource "app/migrations/sql/0156-add-storage-object-json-dedup-index.sql")} + + {:name "0157-add-file-branch-table" + :fn (mg/resource "app/migrations/sql/0157-add-file-branch-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0157-add-file-branch-table.sql b/backend/src/app/migrations/sql/0157-add-file-branch-table.sql new file mode 100644 index 0000000000..c323abe8dc --- /dev/null +++ b/backend/src/app/migrations/sql/0157-add-file-branch-table.sql @@ -0,0 +1,26 @@ +CREATE TABLE file_branch ( + id uuid PRIMARY KEY, + + created_at timestamptz NOT NULL DEFAULT now(), + modified_at timestamptz NOT NULL DEFAULT now(), + merged_at timestamptz NULL, + + core_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE, + file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE, + profile_id uuid NULL REFERENCES profile(id) ON DELETE SET NULL DEFERRABLE, + + name text NOT NULL, + status text NOT NULL DEFAULT 'open', + + base_snapshot_id uuid NOT NULL, + base_revn bigint NOT NULL, + + --- Maps the ids the branch copy remapped (file id, media ids) back to + --- the core file ids + id_index jsonb NOT NULL, + + UNIQUE (file_id) +); + +CREATE INDEX file_branch__core_file_id__idx + ON file_branch(core_file_id); diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index a558315376..ae2fd04d12 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -379,6 +379,7 @@ 'app.rpc.commands.files-share 'app.rpc.commands.files-update 'app.rpc.commands.files-snapshot + 'app.rpc.commands.files-branch 'app.rpc.commands.files-thumbnails 'app.rpc.commands.ldap 'app.rpc.commands.management diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index e10dcd4b9e..a565b6604d 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -134,6 +134,7 @@ [:is-shared ::sm/boolean] [:project-id ::sm/uuid] [:created-at ::ct/inst] + [:branch-count {:optional true} [::sm/int {:min 0}]] [:data {:optional true} ::sm/any]]) (def schema:permissions-mixin @@ -282,7 +283,12 @@ f.vern, f.is_shared, ft.media_id AS thumbnail_id, - p.team_id + p.team_id, + (select count(*) + from file_branch as fb + inner join file as bf on (bf.id = fb.file_id) + where fb.core_file_id = f.id + and bf.deleted_at is null) AS branch_count from file as f inner join project as p on (p.id = f.project_id) left join file_thumbnail as ft on (ft.file_id = f.id @@ -290,6 +296,7 @@ and ft.deleted_at is null) where f.project_id = ? and f.deleted_at is null + and not exists (select 1 from file_branch as fb where fb.file_id = f.id) order by f.modified_at desc") (defn get-project-files @@ -777,7 +784,12 @@ f.is_shared, ft.media_id AS thumbnail_id, row_number() over w as row_num, - p.team_id + p.team_id, + (select count(*) + from file_branch as fb + inner join file as bf on (bf.id = fb.file_id) + where fb.core_file_id = f.id + and bf.deleted_at is null) AS branch_count from file as f inner join project as p on (p.id = f.project_id) left join file_thumbnail as ft on (ft.file_id = f.id @@ -786,6 +798,7 @@ where p.team_id = ? and p.deleted_at is null and f.deleted_at is null + and not exists (select 1 from file_branch as fb where fb.file_id = f.id) window w as (partition by f.project_id order by f.modified_at desc) order by f.modified_at desc ) @@ -1079,7 +1092,7 @@ [:map {:title "delete-file"} [:id ::sm/uuid]]) -(defn- delete-file +(defn delete-file [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}] (check-edition-permissions! conn profile-id id) (let [team (teams/get-team conn diff --git a/backend/src/app/rpc/commands/files_branch.clj b/backend/src/app/rpc/commands/files_branch.clj new file mode 100644 index 0000000000..ba12569085 --- /dev/null +++ b/backend/src/app/rpc/commands/files_branch.clj @@ -0,0 +1,263 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.rpc.commands.files-branch + "Design branches: identity-preserving copies of a file that merge + back into it with a three-way merge." + (:require + [app.binfile.common :as bfc] + [app.common.exceptions :as ex] + [app.common.files.merge :as fmerge] + [app.common.files.validate :as fval] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.db :as db] + [app.features.fdata :as fdata] + [app.features.file-snapshots :as fsnap] + [app.msgbus :as mbus] + [app.rpc :as-alias rpc] + [app.rpc.commands.files :as files] + [app.rpc.commands.management :as management] + [app.rpc.doc :as-alias doc] + [app.util.services :as sv])) + +(defn- decode-branch + [row] + (some-> row (update :id-index db/decode-transit-pgobject))) + +(defn- get-branch-by-file + [conn file-id] + (decode-branch (db/get* conn :file-branch {:file-id file-id}))) + +(def ^:private sql:get-core-branches + "SELECT fb.id, fb.created_at, fb.modified_at, fb.merged_at, fb.core_file_id, + fb.file_id, fb.profile_id, fb.name, fb.status, fb.base_snapshot_id, + fb.base_revn + FROM file_branch AS fb + JOIN file AS f ON (f.id = fb.file_id) + WHERE fb.core_file_id = ? + AND f.deleted_at IS NULL + ORDER BY fb.created_at DESC") + +(defn- get-core-branches + [conn core-file-id] + (db/exec! conn [sql:get-core-branches core-file-id])) + +;; --- COMMAND: create-file-branch + +(def ^:private schema:create-file-branch + [:map {:title "create-file-branch"} + [:file-id ::sm/uuid] + [:name [:string {:min 1 :max 120}]]]) + +(sv/defmethod ::create-file-branch + "Create a branch of a file. The branch keeps every shape, component + and asset id of the core file." + {::doc/added "2.19" + ::sm/params schema:create-file-branch + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id name]}] + (files/check-read-permissions! conn profile-id file-id) + + (let [core (bfc/get-file cfg file-id :realize? true)] + (when (get-branch-by-file conn file-id) + (ex/raise :type :validation + :code :file-is-branch + :hint "a branch can not be branched")) + + (let [snapshot (fsnap/create! cfg core + {:label (str "branch/" name) + :profile-id profile-id + :created-by "user"}) + branch-id (uuid/next) + state (volatile! {:index {file-id branch-id}})] + + (db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"]) + (binding [bfc/*state* state] + (management/duplicate-file (assoc cfg ::bfc/timestamp (ct/now)) + {:profile-id profile-id + :file-id file-id + :name (str (:name core) " / " name) + :reset-shared-flag true})) + + (let [id-index (into {} (map (fn [[old new]] [new old])) (:index @state))] + (-> (db/insert! conn :file-branch + {:id (uuid/next) + :core-file-id file-id + :file-id branch-id + :profile-id profile-id + :name name + :base-snapshot-id (:id snapshot) + :base-revn (:revn core) + :id-index (db/tjson id-index)}) + (dissoc :id-index) + (assoc :project-id (:project-id core))))))) + +;; --- COMMAND QUERY: get-file-branches + +(def ^:private schema:get-file-branches + [:map {:title "get-file-branches"} + [:file-id ::sm/uuid]]) + +(sv/defmethod ::get-file-branches + "Return the core file of `file-id` (itself when it is not a branch), + every branch of that core file, and the branch row when `file-id` is + a branch." + {::doc/added "2.19" + ::sm/params schema:get-file-branches} + [cfg {:keys [::rpc/profile-id file-id]}] + (db/run! cfg (fn [{:keys [::db/conn]}] + (files/check-read-permissions! conn profile-id file-id) + (let [branch (get-branch-by-file conn file-id) + core-id (or (:core-file-id branch) file-id)] + {:branch (some-> branch (dissoc :id-index)) + :core (-> (db/get conn :file {:id core-id}) + (select-keys [:id :name :project-id :is-shared])) + :branches (get-core-branches conn core-id)})))) + +;; --- Merge + +(defn- relink-to-core + "Map the ids the branch copy remapped back to the core ids." + [cfg branch-file {:keys [core-file-id id-index]}] + (binding [bfc/*state* (volatile! {:index id-index})] + (:data (bfc/process-file cfg (assoc branch-file :id core-file-id))))) + +(defn- compute-merge + [cfg {:keys [core-file-id base-snapshot-id] :as branch} opts] + (let [core (bfc/get-file cfg core-file-id :realize? true) + snapshot (fsnap/get-snapshot cfg core-file-id base-snapshot-id) + base (:data (fdata/realize cfg (assoc snapshot :id core-file-id))) + theirs (relink-to-core cfg (bfc/get-file cfg (:file-id branch) :realize? true) branch) + result (fmerge/merge-file-data base (:data core) theirs opts)] + (assoc result :core core))) + +(defn- get-branch-for-merge + [conn file-id] + (let [branch (get-branch-by-file conn file-id)] + (when-not branch + (ex/raise :type :not-found + :code :branch-not-found + :hint "the file is not a branch")) + branch)) + +(def ^:private schema:get-file-branch-diff + [:map {:title "get-file-branch-diff"} + [:file-id ::sm/uuid]]) + +(sv/defmethod ::get-file-branch-diff + "Compute what the branch changed since it was created and the + conflicts with the current core file. Changes nothing." + {::doc/added "2.19" + ::sm/params schema:get-file-branch-diff} + [cfg {:keys [::rpc/profile-id file-id]}] + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-read-permissions! conn profile-id file-id) + (let [branch (get-branch-for-merge conn file-id) + _ (files/check-read-permissions! conn profile-id (:core-file-id branch)) + result (compute-merge cfg branch nil)] + {:branch (dissoc branch :id-index) + :core-revn (-> result :core :revn) + :changes (:changes result) + :conflicts (:conflicts result)})))) + +;; --- COMMAND: merge-file-branch + +(def ^:private schema:merge-file-branch + [:map {:title "merge-file-branch"} + [:file-id ::sm/uuid] + [:resolutions {:optional true} [:map-of :string [:enum "ours" "theirs"]]] + [:excluded {:optional true} [::sm/set :string]]]) + +(sv/defmethod ::merge-file-branch + "Merge a branch into its core file. The core file is snapshotted + first, so the merge can be reverted from the version history." + {::doc/added "2.19" + ::sm/params schema:merge-file-branch} + [{:keys [::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id resolutions excluded]}] + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (let [branch (get-branch-for-merge conn file-id) + core-id (:core-file-id branch) + _ (files/check-edition-permissions! conn profile-id core-id) + _ (bfc/get-minimal-file conn core-id {::db/for-update true}) + resolutions (update-vals resolutions keyword) + {:keys [core data conflicts changes]} + (compute-merge cfg branch {:resolutions resolutions :excluded excluded}) + unresolved (remove #(contains? resolutions (:id %)) conflicts)] + + (when (not= "open" (:status branch)) + (ex/raise :type :validation + :code :branch-not-open + :hint "the branch is already merged")) + + (when (every? #(contains? excluded (:key %)) changes) + (ex/raise :type :validation + :code :nothing-to-merge + :hint "select at least one change to merge")) + + (when (seq unresolved) + (ex/raise :type :validation + :code :unresolved-conflicts + :hint "every conflict needs a resolution" + :conflicts (mapv :id unresolved))) + + (let [file (-> core + (assoc :data data) + (update :revn inc) + (assoc :vern (rand-int Integer/MAX_VALUE)) + (assoc :modified-at (ct/now)))] + + (fval/validate-file-schema! file) + (fval/validate-file! file (bfc/get-resolved-file-libraries cfg file)) + + (fsnap/create! cfg core + {:label (str "before-merge/" (:name branch)) + :profile-id profile-id + :created-by "user"}) + + (bfc/update-file! cfg file) + + (db/update! conn :file-branch + {:status "merged" + :merged-at (ct/now) + :modified-at (ct/now)} + {:id (:id branch)}) + + (mbus/pub! msgbus + :topic core-id + :message {:type :file-restored + :session-id session-id + :file-id core-id + :vern (:vern file)}) + + {:file-id core-id + :project-id (:project-id core) + :revn (:revn file)}))))) + +;; --- COMMAND: delete-file-branch + +(def ^:private schema:delete-file-branch + [:map {:title "delete-file-branch"} + [:file-id ::sm/uuid]]) + +(sv/defmethod ::delete-file-branch + "Delete a branch, open or merged: the branch file goes through the + normal file deletion and its base snapshot is removed from the core + file history." + {::doc/added "2.19" + ::sm/params schema:delete-file-branch + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id]}] + (let [branch (get-branch-for-merge conn file-id)] + (files/delete-file cfg {:profile-id profile-id :id file-id}) + (fsnap/delete! cfg + :id (:base-snapshot-id branch) + :file-id (:core-file-id branch) + :deleted-at (ct/now)) + (db/delete! conn :file-branch {:id (:id branch)}) + {:core-file-id (:core-file-id branch)})) diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index e69c704270..b23c91cb18 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -82,7 +82,8 @@ coalesce(tpp.is_pinned, false) as is_pinned, (SELECT count(*) FROM file AS f WHERE f.project_id = p.id - AND f.deleted_at is null) AS count, + AND f.deleted_at is null + AND NOT EXISTS (SELECT 1 FROM file_branch AS fb WHERE fb.file_id = f.id)) AS count, (SELECT count(*) FROM file AS f WHERE f.project_id = p.id) AS total_count FROM project AS p diff --git a/backend/src/app/rpc/commands/search.clj b/backend/src/app/rpc/commands/search.clj index 1186b15554..b4dadccd5d 100644 --- a/backend/src/app/rpc/commands/search.clj +++ b/backend/src/app/rpc/commands/search.clj @@ -50,6 +50,7 @@ inner join projects as pr on (f.project_id = pr.id) where f.name ilike ('%' || ? || '%') and (f.deleted_at is null) + and not exists (select 1 from file_branch as fb where fb.file_id = f.id) order by f.created_at asc") (defn search-files diff --git a/backend/test/backend_tests/rpc_file_branch_test.clj b/backend/test/backend_tests/rpc_file_branch_test.clj new file mode 100644 index 0000000000..a099ffcb59 --- /dev/null +++ b/backend/test/backend_tests/rpc_file_branch_test.clj @@ -0,0 +1,184 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-file-branch-test + (:require + [app.binfile.common :as bfc] + [app.common.features :as cfeat] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.db :as db] + [app.rpc :as-alias rpc] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(def ^:private page-id (uuid/custom 1 1)) +(def ^:private shape-id (uuid/custom 2 1)) + +(defn- update-file! + [profile-id file-id changes] + (let [out (th/command! {::th/type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id (uuid/random) + :revn 0 + :vern 0 + :features cfeat/supported-features + :changes changes})] + (t/is (nil? (:error out))) + (:result out))) + +(defn- set-attr + [attr val] + [{:type :mod-obj + :page-id page-id + :id shape-id + :operations [{:type :set :attr attr :val val}]}]) + +(defn- prepare-library + [profile is-shared] + (let [file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared is-shared})] + (update-file! (:id profile) (:id file) + [{:type :add-page :name "Page" :id page-id} + {:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape {:id shape-id + :name "Rect" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect})}]) + file)) + +(defn- command! + [profile type params] + (th/command! (merge {::th/type type ::rpc/profile-id (:id profile)} params))) + +(defn- get-shape + [file-id] + (-> (bfc/get-file th/*system* file-id :realize? true) + (get-in [:data :pages-index page-id :objects shape-id]))) + +(t/deftest a-branch-can-not-be-branched + (let [profile (th/create-profile* 1 {:is-active true}) + file (prepare-library profile false) + branch (:result (command! profile :create-file-branch {:file-id (:id file) :name "b1"})) + out (command! profile :create-file-branch {:file-id (:file-id branch) :name "b2"})] + (t/is (uuid? (:file-id branch))) + (t/is (= :file-is-branch (th/ex-code (:error out)))))) + +(t/deftest branch-and-merge + (let [profile (th/create-profile* 1 {:is-active true}) + core (prepare-library profile true) + out (command! profile :create-file-branch {:file-id (:id core) :name "b1"}) + branch (:result out)] + (t/is (nil? (:error out))) + + (t/testing "the branch keeps shape ids and is not shared" + (let [file (bfc/get-file th/*system* (:file-id branch))] + (t/is (false? (:is-shared file))) + (t/is (= "Rect" (:name (get-shape (:file-id branch))))))) + + (update-file! (:id profile) (:file-id branch) (set-attr :name "From branch")) + (update-file! (:id profile) (:id core) (set-attr :opacity 0.5)) + + (t/testing "branch info names its core file" + (let [{:keys [result error]} (command! profile :get-file-branches {:file-id (:file-id branch)})] + (t/is (nil? error)) + (t/is (= {:id (:id core) :name (:name core) :project-id (:project-id core) :is-shared true} + (:core result))) + (t/is (= [(:file-id branch)] (map :file-id (:branches result)))))) + + (t/testing "diff lists the branch change and no conflict" + (let [{:keys [result error]} (command! profile :get-file-branch-diff {:file-id (:file-id branch)})] + (t/is (nil? error)) + (t/is (empty? (:conflicts result))) + (t/is (= [[:shape :mod]] (map (juxt :kind :op) (:changes result)))))) + + (t/testing "merge keeps both edits" + (let [{:keys [error]} (command! profile :merge-file-branch {:file-id (:file-id branch)}) + shape (get-shape (:id core))] + (t/is (nil? error)) + (t/is (= "From branch" (:name shape))) + (t/is (= 0.5 (:opacity shape))) + (t/is (= "merged" (:status (db/get th/*system* :file-branch {:id (:id branch)})))))))) + +(t/deftest merge-requires-resolutions + (let [profile (th/create-profile* 1 {:is-active true}) + core (prepare-library profile true) + branch (:result (command! profile :create-file-branch {:file-id (:id core) :name "b1"}))] + + (update-file! (:id profile) (:file-id branch) (set-attr :name "Theirs")) + (update-file! (:id profile) (:id core) (set-attr :name "Ours")) + + (let [{:keys [result]} (command! profile :get-file-branch-diff {:file-id (:file-id branch)}) + [conflict] (:conflicts result)] + (t/is (= 1 (count (:conflicts result)))) + + (let [{:keys [error]} (command! profile :merge-file-branch {:file-id (:file-id branch)})] + (t/is (= :unresolved-conflicts (th/ex-code error)))) + + (let [{:keys [error]} (command! profile :merge-file-branch + {:file-id (:file-id branch) + :resolutions {(:id conflict) "theirs"}})] + (t/is (nil? error)) + (t/is (= "Theirs" (:name (get-shape (:id core))))))))) + +(t/deftest delete-branch + (let [profile (th/create-profile* 1 {:is-active true}) + core (prepare-library profile false) + branch (:result (command! profile :create-file-branch {:file-id (:id core) :name "b1"}))] + + (command! profile :merge-file-branch {:file-id (:file-id branch)}) + + (let [{:keys [result error]} (command! profile :delete-file-branch {:file-id (:file-id branch)})] + (t/is (nil? error)) + (t/is (= (:id core) (:core-file-id result)))) + + (t/is (nil? (db/get* th/*system* :file-branch {:id (:id branch)}))) + (t/is (some? (:deleted-at (db/get* th/*system* :file {:id (:file-id branch)} + {::db/remove-deleted false})))) + (t/is (empty? (:branches (:result (command! profile :get-file-branches {:file-id (:id core)}))))))) + +(t/deftest selective-merge + (let [profile (th/create-profile* 1 {:is-active true}) + core (prepare-library profile false) + branch (:result (command! profile :create-file-branch {:file-id (:id core) :name "b1"}))] + + (update-file! (:id profile) (:file-id branch) (set-attr :name "Theirs")) + + (let [{:keys [result]} (command! profile :get-file-branch-diff {:file-id (:file-id branch)}) + keys (into #{} (map :key) (:changes result))] + + (t/testing "excluding every change is refused" + (let [{:keys [error]} (command! profile :merge-file-branch {:file-id (:file-id branch) + :excluded keys})] + (t/is (= :nothing-to-merge (th/ex-code error))))) + + (t/testing "an included change is merged" + (let [{:keys [error]} (command! profile :merge-file-branch {:file-id (:file-id branch) + :excluded #{}})] + (t/is (nil? error)) + (t/is (= "Theirs" (:name (get-shape (:id core)))))))))) + +(t/deftest dashboard-lists-hide-branches + (let [profile (th/create-profile* 1 {:is-active true}) + core (prepare-library profile false) + branch (:result (command! profile :create-file-branch {:file-id (:id core) :name "b1"})) + files (:result (command! profile :get-project-files {:project-id (:default-project-id profile)})) + recent (:result (command! profile :get-team-recent-files {:team-id (:default-team-id profile)}))] + (t/is (= [(:id core)] (map :id files))) + (t/is (= [1] (map :branch-count files))) + (t/is (= [(:id core)] (map :id recent))) + (t/is (not-any? #(= (:file-id branch) (:id %)) recent)))) diff --git a/common/src/app/common/files/merge.cljc b/common/src/app/common/files/merge.cljc new file mode 100644 index 0000000000..3c6ab300d6 --- /dev/null +++ b/common/src/app/common/files/merge.cljc @@ -0,0 +1,389 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.common.files.merge + "Three-way merge of file data. + + `base` is the data at the branch point, `ours` the current data of + the core file and `theirs` the current data of the branch. Entities + keep their ids across the three sides, so they are matched by id. + + Shape attrs are merged per sync group (`ctk/sync-attrs`), so derived + geometry always travels with the attrs it derives from. An attr group + changed on one side is taken; changed on both sides to different + values is a conflict. Unresolved conflicts keep the `ours` value." + (:require + [app.common.data :as d] + [app.common.types.component :as ctk] + [app.common.uuid :as uuid] + [clojure.core.protocols :as cp] + [clojure.set :as set])) + +(def ^:private entity-collections + [:components :colors :typographies :media]) + +(def ^:private handled-keys + (into #{:id :pages :pages-index} entity-collections)) + +(def ^:private tree-attrs + #{:parent-id :frame-id}) + +(def ^:private color-value-attrs + #{:color :opacity :gradient :image}) + +(defn- shape-group + [shape attr] + (cond + (= attr :shapes) :shapes + (contains? tree-attrs attr) :tree + :else + (let [group (get ctk/sync-attrs attr)] + (cond + (map? group) (get group (:type shape) attr) + (some? group) group + :else attr)))) + +(defn- color-group + [_ attr] + (if (contains? color-value-attrs attr) :value attr)) + +(defn- attr-group + [_ attr] + attr) + +(defn- datafy-eq + [a b] + (= (cp/datafy a) (cp/datafy b))) + +(defn change-key + "Stable key of one changed entity; `merge-file-data` takes a set of + them as `:excluded`." + [kind page-id id] + (str (name kind) "/" (or page-id "") "/" (if (keyword? id) (name id) id))) + +(defn conflict-id + [kind page-id id group] + (str (change-key kind page-id id) "/" (if (keyword? group) (name group) group))) + +;; --- Sequences + +(defn merge-seq + "Merge two edits of the `base` id vector. Starts from `ours`, drops + the ids `theirs` removed and inserts the ids `theirs` added after + their nearest preceding neighbour." + [base ours theirs] + (cond + (= base theirs) ours + (= base ours) theirs + :else + (let [base-set (set base) + theirs-set (set theirs) + removed (set/difference base-set theirs-set) + ours' (into [] (remove removed) ours) + ours-set (set ours') + added (into [] (remove #(or (contains? base-set %) + (contains? ours-set %))) + theirs)] + (reduce (fn [result id] + (let [prev (->> theirs + (take-while #(not= % id)) + (filter (set result)) + (last)) + index (if prev (inc (d/index-of result prev)) 0)] + (d/insert-at-index result index [id]))) + ours' + added)))) + +;; --- Entities + +(defn- group-keys + [group-fn entity keys] + (group-by #(group-fn entity %) keys)) + +(defn- merge-entity + "Merge one entity present on the three sides. Returns + `{:value v :conflicts [..]}`." + [ctx base ours theirs] + (let [{:keys [kind page-id group-fn resolutions eq]} ctx + id (:id ours) + all (-> #{} (into (keys base)) (into (keys ours)) (into (keys theirs))) + groups (group-keys group-fn ours all)] + (reduce-kv + (fn [{:keys [value] :as result} group ks] + (let [pick (fn [entity] (select-keys entity ks)) + b (pick base) + o (pick ours) + t (pick theirs) + take-side (fn [value side] (-> (apply dissoc value ks) (merge side)))] + (cond + (eq b t) result + (eq b o) (assoc result :value (take-side value t)) + + (= group :shapes) + (assoc result :value + (assoc value :shapes (merge-seq (:shapes base) (:shapes ours) (:shapes theirs)))) + + (eq o t) result + + :else + (let [cid (conflict-id kind page-id id group)] + (cond-> (update result :conflicts conj + {:id cid :key (change-key kind page-id id) :kind kind + :page-id page-id :entity-id id + :name (:name ours) :group group :attrs (vec (sort ks)) + :base b :ours o :theirs t}) + (= :theirs (get resolutions cid)) + (assoc :value (take-side value t))))))) + {:value ours :conflicts []} + groups))) + +(defn- change-entry + [ctx id base theirs] + (let [{:keys [kind page-id group-fn eq owner-fn]} ctx + entry (fn [op name & {:as attrs}] + (merge {:key (change-key kind page-id id) + :kind kind :page-id page-id :id id :op op :name name} + (when owner-fn (owner-fn id)) + attrs))] + (cond + (nil? base) (entry :add (:name theirs)) + (nil? theirs) (entry :del (:name base)) + :else + (let [all (-> #{} (into (keys base)) (into (keys theirs))) + groups (->> (group-keys group-fn theirs all) + (keep (fn [[group ks]] + (when-not (eq (select-keys base ks) (select-keys theirs ks)) + group))) + (sort-by str) + (vec))] + (when (seq groups) + (entry :mod (:name theirs) :groups groups)))))) + +(defn- merge-entities + "Merge three `id -> entity` maps. Returns + `{:value map :conflicts [..] :changes [..]}`." + [ctx base ours theirs] + (let [{:keys [kind page-id resolutions excluded eq]} ctx + ids (-> #{} (into (keys base)) (into (keys ours)) (into (keys theirs)))] + (reduce + (fn [result id] + (let [b (get base id) + o (get ours id) + t (get theirs id) + result (if-let [change (change-entry ctx id b t)] + (update result :changes conj change) + result) + t (if (contains? excluded (change-key kind page-id id)) b t) + delete-conflict + (fn [result] + (let [cid (conflict-id kind page-id id :entity)] + (cond-> (update result :conflicts conj + {:id cid :key (change-key kind page-id id) :kind kind + :page-id page-id :entity-id id + :name (:name (or o t b)) :group :entity + :ours (some? o) :theirs (some? t)}) + (= :theirs (get resolutions cid)) + (update :value (fn [m] (if t (assoc m id t) (dissoc m id)))))))] + (cond + (eq t b) result + + (nil? b) + (cond + (nil? o) (update result :value assoc id t) + (eq o t) result + :else (delete-conflict result)) + + (nil? t) + (cond + (nil? o) result + (eq o b) (update result :value dissoc id) + :else (delete-conflict result)) + + (nil? o) + (delete-conflict result) + + :else + (let [{:keys [value conflicts]} (merge-entity ctx b o t)] + (-> result + (update :value assoc id value) + (update :conflicts into conflicts)))))) + {:value (or ours {}) :conflicts [] :changes []} + (sort-by str ids)))) + +;; --- Shape tree + +(defn- repair-tree + "Drop shapes whose parent no longer exists and make every `:shapes` + vector agree with the children `:parent-id`." + [objects] + (let [objects + (loop [objects objects] + (let [orphans (into #{} + (comp (remove #(= uuid/zero (:id %))) + (remove #(contains? objects (:parent-id %))) + (map :id)) + (vals objects))] + (if (empty? orphans) + objects + (recur (apply dissoc objects orphans))))) + + children + (reduce-kv (fn [index id shape] + (if (= id uuid/zero) + index + (update index (:parent-id shape) (fnil conj #{}) id))) + {} + objects)] + + (reduce-kv + (fn [objects id shape] + (let [own (get children id #{}) + shapes (into [] (filter own) (:shapes shape)) + missing (remove (set shapes) (sort-by str own)) + shapes (into shapes missing)] + (if (or (seq shapes) (contains? shape :shapes)) + (assoc-in objects [id :shapes] shapes) + objects))) + objects + objects))) + +;; --- File data + +(defn- merge-top-level + [{:keys [resolutions excluded]} base ours theirs] + (let [ks (-> #{} (into (keys base)) (into (keys ours)) (into (keys theirs)) + (set/difference handled-keys))] + (reduce + (fn [result k] + (let [eq (if (= k :tokens-lib) datafy-eq =) + b (get base k) + o (get ours k) + key (change-key :data nil k) + t (if (contains? excluded key) b (get theirs k)) + cid (conflict-id :data nil k :value) + change {:key key :kind :data :id k :op :mod :name (name k)}] + (cond + (eq b (get theirs k)) result + (eq b t) (update result :changes conj change) + (eq b o) (-> result + (update :value (fn [data] (if (some? t) (assoc data k t) (dissoc data k)))) + (update :changes conj change)) + (eq o t) (update result :changes conj change) + :else + (cond-> (-> result + (update :changes conj change) + (update :conflicts conj + {:id cid :key key :kind :data :entity-id k :name (name k) + :group :value :ours (some? o) :theirs (some? t)})) + (= :theirs (get resolutions cid)) + (update :value (fn [data] (if (some? t) (assoc data k t) (dissoc data k)))))))) + {:value ours :conflicts [] :changes []} + (sort-by str ks)))) + +(defn- combine + [result {:keys [conflicts changes]}] + (-> result + (update :conflicts into conflicts) + (update :changes into changes))) + +(defn- page-meta + [page] + (dissoc page :objects)) + +(defn- component-owner-fn + "Return a fn that finds the component a shape belongs to: the nearest + main instance up its parent chain, in `theirs` or else in `base`." + [base theirs page-id] + (let [objects (fn [data] (get-in data [:pages-index page-id :objects])) + theirs-obj (objects theirs) + base-obj (objects base) + lookup #(or (get theirs-obj %) (get base-obj %))] + (fn [id] + (loop [id id] + (when-let [shape (when (not= id uuid/zero) (lookup id))] + (if-let [component-id (when (:main-instance shape) (:component-id shape))] + {:component-id component-id + :component-name (:name (or (get-in theirs [:components component-id]) + (get-in base [:components component-id])))} + (recur (:parent-id shape)))))))) + +(defn merge-file-data + "Three-way merge of file data. Options: + + - `:resolutions` maps a conflict id to `:ours` or `:theirs`. + - `:excluded` is a set of change keys (see `change-key`) whose + `theirs` side is ignored, so the core keeps its own version. + + Returns `{:data merged :conflicts [..] :changes [..]}`, where + `changes` lists what `theirs` changed since `base`." + ([base ours theirs] + (merge-file-data base ours theirs nil)) + ([base ours theirs {:keys [resolutions excluded] :as opts}] + (let [ctx {:resolutions resolutions :excluded (set excluded) :eq =} + + top (merge-top-level ctx base ours theirs) + result (assoc top :data (:value top)) + + result + (reduce + (fn [result coll] + (let [group-fn (if (#{:colors} coll) color-group attr-group) + merged (merge-entities (cond-> (assoc ctx :kind coll :group-fn group-fn) + (= coll :components) + (assoc :owner-fn (fn [id] + {:component-id id + :component-name (:name (or (get-in theirs [:components id]) + (get-in base [:components id])))}))) + (get base coll) (get ours coll) (get theirs coll))] + (-> result + (combine merged) + (assoc-in [:data coll] (:value merged))))) + result + entity-collections) + + pages + (merge-entities (assoc ctx :kind :page :group-fn attr-group) + (update-vals (:pages-index base) page-meta) + (update-vals (:pages-index ours) page-meta) + (update-vals (:pages-index theirs) page-meta)) + + result (combine result pages) + + pages-index + (reduce-kv + (fn [index page-id page] + (let [objects (fn [data] (get-in data [:pages-index page-id :objects])) + merged (merge-entities (assoc ctx + :kind :shape + :page-id page-id + :group-fn shape-group + :owner-fn (component-owner-fn base theirs page-id)) + (objects base) (objects ours) (objects theirs))] + (assoc index page-id (-> merged + (update :value repair-tree) + (assoc :page page))))) + {} + (:value pages)) + + result + (reduce (fn [result merged] (combine result merged)) + result + (vals pages-index)) + + page-ids + (->> (merge-seq (:pages base) (:pages ours) (:pages theirs)) + (filterv #(contains? pages-index %))) + + page-ids + (into page-ids (remove (set page-ids)) (sort-by str (keys pages-index)))] + + (-> result + (dissoc :value) + (assoc-in [:data :pages] page-ids) + (assoc-in [:data :pages-index] + (update-vals pages-index + (fn [{:keys [page value]}] + (assoc page :objects value)))))))) diff --git a/common/test/common_tests/files/merge_test.cljc b/common/test/common_tests/files/merge_test.cljc new file mode 100644 index 0000000000..d51e6ae17d --- /dev/null +++ b/common/test/common_tests/files/merge_test.cljc @@ -0,0 +1,134 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns common-tests.files.merge-test + (:require + [app.common.files.merge :as fmerge] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +(defn- base-file + [] + (-> (thf/sample-file :file1) + (ths/add-sample-shape :frame1 :type :frame :name "Frame") + (ths/add-sample-shape :rect1 :parent-label :frame1 :name "Rect") + (ths/add-sample-library-color :color1 :name "Primary" :color "#ff0000"))) + +(defn- merge-files + ([base ours theirs] + (merge-files base ours theirs nil)) + ([base ours theirs opts] + (let [result (fmerge/merge-file-data (:data base) (:data ours) (:data theirs) opts)] + (assoc result :file (assoc ours :data (:data result)))))) + +(defn- shape + [file label] + (ths/get-shape file label)) + +(t/deftest merge-seq + (t/is (= [:a :b :c] (fmerge/merge-seq [:a :b] [:a :b] [:a :b :c]))) + (t/is (= [:x :a :b :c] (fmerge/merge-seq [:a :b] [:x :a :b] [:a :b :c]))) + (t/is (= [:x :a :c] (fmerge/merge-seq [:a :b] [:x :a :b] [:a :c]))) + (t/is (= [:a :y :c] (fmerge/merge-seq [:a :b :c] [:a :b :c] [:a :y :c]))) + (t/is (= [:a :t :o] (fmerge/merge-seq [:a] [:a :o] [:a :t])))) + +(t/deftest one-side-changes-are-taken + (let [base (base-file) + ours (ths/update-shape base :rect1 :name "Renamed") + theirs (ths/update-shape base :rect1 :opacity 0.5) + {:keys [file conflicts changes]} (merge-files base ours theirs)] + (t/is (empty? conflicts)) + (t/is (= "Renamed" (:name (shape file :rect1)))) + (t/is (= 0.5 (:opacity (shape file :rect1)))) + (t/is (= [{:kind :shape :op :mod :name "Rect" :groups [:layer-effects-group]}] + (map #(select-keys % [:kind :op :name :groups]) changes))) + (thf/validate-file! file))) + +(t/deftest same-group-edits-conflict + (let [base (base-file) + ours (ths/update-shape base :rect1 :opacity 0.2) + theirs (ths/update-shape base :rect1 :opacity 0.5) + {:keys [file conflicts]} (merge-files base ours theirs) + [conflict] conflicts] + (t/is (= 1 (count conflicts))) + (t/is (= :layer-effects-group (:group conflict))) + (t/is (= 0.2 (:opacity (shape file :rect1)))) + + (let [{:keys [file]} (merge-files base ours theirs {:resolutions {(:id conflict) :theirs}})] + (t/is (= 0.5 (:opacity (shape file :rect1))))))) + +(t/deftest equal-edits-do-not-conflict + (let [base (base-file) + ours (ths/update-shape base :rect1 :opacity 0.5) + theirs (ths/update-shape base :rect1 :opacity 0.5) + {:keys [conflicts]} (merge-files base ours theirs)] + (t/is (empty? conflicts)))) + +(t/deftest both-sides-add-children + (let [base (base-file) + ours (ths/add-sample-shape base :rect-ours :parent-label :frame1 :name "Ours") + theirs (ths/add-sample-shape base :rect-theirs :parent-label :frame1 :name "Theirs") + {:keys [file conflicts]} (merge-files base ours theirs)] + (t/is (empty? conflicts)) + (t/is (= #{(thi/id :rect1) (thi/id :rect-ours) (thi/id :rect-theirs)} + (set (:shapes (shape file :frame1))))) + (thf/validate-file! file))) + +(t/deftest delete-versus-modify-conflicts + (let [base (base-file) + page (thf/current-page base) + ours (ths/update-shape base :rect1 :opacity 0.2) + theirs (-> base + (update-in [:data :pages-index (:id page) :objects] dissoc (thi/id :rect1)) + (update-in [:data :pages-index (:id page) :objects (thi/id :frame1) :shapes] + (fn [shapes] (filterv #(not= % (thi/id :rect1)) shapes)))) + {:keys [file conflicts]} (merge-files base ours theirs)] + (t/is (= [:entity] (map :group conflicts))) + (t/is (some? (shape file :rect1))) + + (let [{:keys [file]} (merge-files base ours theirs {:resolutions {(:id (first conflicts)) :theirs}})] + (t/is (nil? (shape file :rect1))) + (t/is (empty? (:shapes (shape file :frame1)))) + (thf/validate-file! file)))) + +(t/deftest library-color-merge + (let [base (base-file) + id (thi/id :color1) + ours (assoc-in base [:data :colors id :name] "Brand") + theirs (assoc-in base [:data :colors id :color] "#00ff00") + {:keys [file conflicts]} (merge-files base ours theirs)] + (t/is (empty? conflicts)) + (t/is (= {:name "Brand" :color "#00ff00"} + (select-keys (get-in file [:data :colors id]) [:name :color]))))) + +(t/deftest excluded-changes-are-not-merged + (let [base (base-file) + ours (ths/update-shape base :rect1 :opacity 0.2) + theirs (-> base + (ths/update-shape :rect1 :opacity 0.5) + (ths/update-shape :frame1 :name "Renamed frame")) + {:keys [changes]} (merge-files base ours theirs) + rect-key (:key (first (filter #(= (thi/id :rect1) (:id %)) changes))) + {:keys [file conflicts]} (merge-files base ours theirs {:excluded #{rect-key}})] + (t/is (= 2 (count changes))) + (t/is (empty? conflicts)) + (t/is (= 0.2 (:opacity (shape file :rect1)))) + (t/is (= "Renamed frame" (:name (shape file :frame1)))) + (thf/validate-file! file))) + +(t/deftest excluded-new-child-is-dropped + (let [base (base-file) + theirs (ths/add-sample-shape base :rect2 :parent-label :frame1 :name "New") + {:keys [changes]} (merge-files base base theirs) + new-key (:key (first (filter #(= (thi/id :rect2) (:id %)) changes))) + {:keys [file]} (merge-files base base theirs {:excluded #{new-key}})] + (t/is (nil? (shape file :rect2))) + (t/is (= [(thi/id :rect1)] (:shapes (shape file :frame1)))) + (thf/validate-file! file))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 728fafda6d..cb26551be7 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -22,6 +22,7 @@ [common-tests.files-migrations-0026-test] [common-tests.files-migrations-test] [common-tests.files.helpers-test] + [common-tests.files.merge-test] [common-tests.files.shapes-builder-test] [common-tests.files.validate-test] [common-tests.geom-align-test] @@ -104,6 +105,7 @@ 'common-tests.files-changes-test 'common-tests.files-builder-test 'common-tests.files.helpers-test + 'common-tests.files.merge-test 'common-tests.files-migrations-0025-test 'common-tests.files-migrations-0026-test 'common-tests.files-migrations-test diff --git a/frontend/src/app/main/data/workspace/layout.cljs b/frontend/src/app/main/data/workspace/layout.cljs index 6c8aed33cc..0f279ee981 100644 --- a/frontend/src/app/main/data/workspace/layout.cljs +++ b/frontend/src/app/main/data/workspace/layout.cljs @@ -20,6 +20,7 @@ :comments :assets :document-history + :design-branches :hide-palettes :colorpalette :element-options diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index b46a0c53c1..b62b9a325e 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -247,10 +247,13 @@ [:span {:class date-class :title (tr "dashboard.deleted.will-be-deleted-at" time)} time]) - (let [time (ct/timeago (:modified-at file))] + (let [time (ct/timeago (:modified-at file)) + branches (:branch-count file 0)] [:span {:class date-class :title (tr "dashboard.grid.last-modified-at" time)} - time])))) + time + (when (pos? branches) + (str " · " branches (if (= 1 branches) " branch" " branches")))])))) (defn create-counter-element [_element file-count] diff --git a/frontend/src/app/main/ui/workspace/branches.cljs b/frontend/src/app/main/ui/workspace/branches.cljs new file mode 100644 index 0000000000..512f7a6879 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/branches.cljs @@ -0,0 +1,349 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.workspace.branches + "Design branches PoC: a sidebar panel to list, open and create the + branches of a file, review what a branch changed, resolve conflicts + and merge it back." + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.common :as dcm] + [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] + [app.main.data.workspace :as dw] + [app.main.repo :as rp] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.checkbox :refer [checkbox*]] + [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.util.dom :as dom] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(defn toggle-panel + [& _] + (st/emit! (dw/remove-layout-flag :document-history) + (dw/toggle-layout-flag :design-branches))) + +(defn- close-panel + [] + (st/emit! (dw/remove-layout-flag :design-branches))) + +(defn- open-file + [file-id] + (st/emit! (dcm/go-to-workspace :file-id file-id))) + +(defn- show-error + [cause] + (st/emit! (ntf/error (or (:hint (ex-data cause)) (ex-message cause) "Unexpected error")))) + +(def ^:private geometry-attrs + [:x :y :width :height :rotation]) + +(defn- d-name + [v] + (if (keyword? v) (name v) (str v))) + +(defn- summarize + [{:keys [group]} value] + (cond + (boolean? value) (if value "exists" "deleted") + (= group :geometry-group) (pr-str (select-keys value geometry-attrs)) + :else (str/prune (pr-str value) 120))) + +(def ^:private op-labels + {:add "added" :del "removed" :mod "modified"}) + +(mf/defc change-row* + {::mf/private true} + [{:keys [change included on-toggle]}] + (let [{:keys [key op kind name groups]} change + on-change (mf/use-fn (mf/deps key on-toggle) #(on-toggle #{key}))] + [:li {:class (stl/css-case :change true :excluded (not included))} + [:div {:class (stl/css :change-title)} + [:> checkbox* {:id key :checked included :on-change on-change}] + [:span {:class (stl/css-case :op true + :op-add (= op :add) + :op-del (= op :del) + :op-mod (= op :mod))} + (get op-labels op)] + [:span {:class (stl/css :name)} (or name "—")] + [:span {:class (stl/css :muted)} (d-name kind)]] + (when (seq groups) + [:div {:class (stl/css :muted)} (str/join ", " (map d-name groups))])])) + +(defn- group-changes + "Group changes by the component they belong to; the rest go last." + [changes] + (->> changes + (group-by :component-id) + (map (fn [[component-id changes]] + {:id (or (some-> component-id str) "other") + :name (if component-id + (or (:component-name (first changes)) "Component") + "Other changes") + :changes changes})) + (sort-by (juxt #(= "other" (:id %)) :name)))) + +(mf/defc change-group* + {::mf/private true} + [{:keys [group excluded on-toggle]}] + (let [{:keys [id name changes]} group + ks (mf/with-memo [changes] (into #{} (map :key) changes)) + included (remove #(contains? excluded (:key %)) changes) + all? (= (count included) (count changes)) + on-change (mf/use-fn (mf/deps ks on-toggle all?) #(on-toggle ks (not all?)))] + [:li {:class (stl/css :group)} + [:div {:class (stl/css :group-title)} + [:> checkbox* {:id (str "group-" id) :checked all? :on-change on-change}] + [:span {:class (stl/css :name)} name] + [:span {:class (stl/css :muted)} (str (count included) "/" (count changes))]] + [:ul {:class (stl/css :list)} + (for [change changes] + [:> change-row* {:key (:key change) + :change change + :included (not (contains? excluded (:key change))) + :on-toggle on-toggle}])]])) + +(mf/defc conflict-row* + {::mf/private true} + [{:keys [conflict selected on-change]}] + (let [{:keys [id kind name group ours theirs]} conflict + on-change (mf/use-fn (mf/deps id on-change) #(on-change id %))] + [:li {:class (stl/css :conflict)} + [:div {:class (stl/css :change-title)} + [:span {:class (stl/css :name)} (or name "—")] + [:span {:class (stl/css :muted)} (str (d-name kind) " · " (d-name group))]] + [:dl {:class (stl/css :values)} + [:dt "Core"] [:dd (summarize conflict ours)] + [:dt "Branch"] [:dd (summarize conflict theirs)]] + [:> radio-buttons* {:name id + :extended true + :selected selected + :on-change on-change + :options [{:id (str id "-ours") :label "Keep core" :value "ours"} + {:id (str id "-theirs") :label "Take branch" :value "theirs"}]}]])) + +(mf/defc branch-review* + {::mf/private true} + [{:keys [branch]}] + (let [diff* (mf/use-state nil) + diff (deref diff*) + resolutions* (mf/use-state {}) + resolutions (deref resolutions*) + excluded* (mf/use-state #{}) + excluded (deref excluded*) + busy* (mf/use-state false) + busy (deref busy*) + + file-id (:file-id branch) + changes (:changes diff) + groups (mf/with-memo [changes] (group-changes changes)) + conflicts (remove #(contains? excluded (:key %)) (:conflicts diff)) + resolved? (every? #(contains? resolutions (:id %)) conflicts) + selected (- (count changes) (count (filter #(contains? excluded (:key %)) changes))) + + load-diff + (mf/use-fn + (mf/deps file-id) + (fn [] + (reset! diff* nil) + (reset! excluded* #{}) + (->> (rp/cmd! :get-file-branch-diff {:file-id file-id}) + (rx/subs! #(reset! diff* %) show-error)))) + + on-resolve + (mf/use-fn #(swap! resolutions* assoc %1 %2)) + + on-toggle + (mf/use-fn + (fn + ([ks] + (swap! excluded* (fn [excluded] + (if (every? excluded ks) + (reduce disj excluded ks) + (into excluded ks))))) + ([ks include?] + (swap! excluded* #(if include? (reduce disj % ks) (into % ks)))))) + + on-merge + (mf/use-fn + (mf/deps file-id resolutions excluded) + (fn [] + (reset! busy* true) + (->> (rp/cmd! :merge-file-branch {:file-id file-id + :resolutions resolutions + :excluded excluded}) + (rx/subs! (fn [{:keys [file-id]}] + (st/emit! (ntf/success "Branch merged into the core file")) + (open-file file-id)) + (fn [cause] + (reset! busy* false) + (show-error cause))))))] + + (mf/with-effect [file-id] + (load-diff)) + + [:section {:class (stl/css :section)} + [:div {:class (stl/css :section-title)} + [:> text* {:as "h3" :typography t/headline-small} + (if diff + (str "Changes (" selected "/" (count changes) " selected)") + "Changes")] + [:> icon-button* {:variant "ghost" + :icon i/reload + :aria-label "Refresh changes" + :on-click load-diff}]] + + (cond + (nil? diff) + [:> text* {:as "p" :typography t/body-small :class (stl/css :muted)} "Computing changes…"] + + (empty? changes) + [:> text* {:as "p" :typography t/body-small :class (stl/css :muted)} "Nothing changed yet."] + + :else + [:ul {:class (stl/css :list)} + (for [group groups] + [:> change-group* {:key (:id group) + :group group + :excluded excluded + :on-toggle on-toggle}])]) + + (when (seq conflicts) + [:* + [:> text* {:as "h3" :typography t/headline-small} + (str "Conflicts (" (count conflicts) ")")] + [:ul {:class (stl/css :list)} + (for [conflict conflicts] + [:> conflict-row* {:key (:id conflict) + :conflict conflict + :selected (get resolutions (:id conflict)) + :on-change on-resolve}])]]) + + [:> button* {:on-click on-merge + :variant "primary" + :class (stl/css :merge) + :disabled (or (nil? diff) (zero? selected) (not resolved?) busy)} + (if (= selected (count changes)) + "Merge into core" + (str "Merge " selected " selected"))]])) + +(mf/defc file-row* + {::mf/private true} + [{:keys [file-id label status is-current on-delete]}] + (let [on-click (mf/use-fn (mf/deps file-id is-current) #(when-not is-current (open-file file-id))) + on-delete (mf/use-fn (mf/deps file-id label on-delete) #(on-delete file-id label))] + [:li {:class (stl/css :file-item)} + [:button {:class (stl/css-case :file-row true :current is-current) + :aria-current (when is-current "page") + :on-click on-click} + [:span {:class (stl/css :name)} label] + [:span {:class (stl/css-case :status true + :status-core (= status "core") + :status-open (= status "open") + :status-merged (= status "merged"))} + status]] + (when on-delete + [:> icon-button* {:variant "ghost" + :icon i/delete + :aria-label (str "Delete branch " label) + :on-click on-delete}])])) + +(mf/defc branches-panel* + [{:keys [file-id]}] + (let [info* (mf/use-state nil) + info (deref info*) + name* (mf/use-state "") + name (deref name*) + + {:keys [branch core branches]} info + + load-info + (mf/use-fn + (mf/deps file-id) + (fn [] + (->> (rp/cmd! :get-file-branches {:file-id file-id}) + (rx/subs! #(reset! info* %) show-error)))) + + on-delete + (mf/use-fn + (mf/deps file-id load-info) + (fn [branch-file-id label] + (st/emit! + (modal/show + {:type :confirm + :title "Delete branch" + :message (str "Delete \"" label "\"? The branch file is deleted. " + "Merged changes stay in the core file.") + :accept-label "Delete branch" + :on-accept + (fn [] + (->> (rp/cmd! :delete-file-branch {:file-id branch-file-id}) + (rx/subs! (fn [{:keys [core-file-id]}] + (if (= branch-file-id file-id) + (open-file core-file-id) + (load-info))) + show-error)))})))) + + on-name-change + (mf/use-fn #(reset! name* (dom/get-target-val %))) + + on-create + (mf/use-fn + (mf/deps core name) + (fn [] + (->> (rp/cmd! :create-file-branch {:file-id (:id core) :name (str/trim name)}) + (rx/subs! (fn [{:keys [file-id]}] + (reset! name* "") + (open-file file-id)) + show-error))))] + + (mf/with-effect [file-id] + (load-info)) + + [:div {:class (stl/css :panel)} + [:header {:class (stl/css :header)} + [:> text* {:as "h2" :typography t/headline-small :class (stl/css :title)} "Branches"] + [:> icon-button* {:variant "ghost" + :icon i/close + :aria-label "Close" + :on-click close-panel}]] + + (if (nil? info) + [:> text* {:as "p" :typography t/body-small :class (stl/css :muted)} "Loading…"] + [:div {:class (stl/css :content)} + [:section {:class (stl/css :section)} + [:ul {:class (stl/css :list)} + [:> file-row* {:file-id (:id core) + :label (:name core) + :status "core" + :is-current (= file-id (:id core))}] + (for [row branches] + [:> file-row* {:key (str (:id row)) + :file-id (:file-id row) + :label (:name row) + :status (:status row) + :is-current (= file-id (:file-id row)) + :on-delete on-delete}])] + + [:div {:class (stl/css :create)} + [:> input* {:placeholder "New branch name" + :value name + :on-change on-name-change}] + [:> button* {:on-click on-create + :variant "secondary" + :disabled (str/blank? name)} + "Create"]]] + + (when (= "open" (:status branch)) + [:> branch-review* {:key (str (:file-id branch)) :branch branch}])])])) diff --git a/frontend/src/app/main/ui/workspace/branches.scss b/frontend/src/app/main/ui/workspace/branches.scss new file mode 100644 index 0000000000..39fb765bb1 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/branches.scss @@ -0,0 +1,207 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +@use "ds/_utils.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/typography.scss" as t; + +.panel { + @include t.use-typography("body-small"); + + display: flex; + flex-direction: column; + height: 100%; + color: var(--color-foreground-secondary); +} + +.title { + margin: 0; + font-weight: 600; + color: var(--color-foreground-secondary); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-xs) var(--sp-s) var(--sp-xs) var(--sp-l); + color: var(--color-foreground-primary); + border-block-end: $b-1 solid var(--color-background-quaternary); +} + +.content { + display: grid; + gap: var(--sp-l); + padding: var(--sp-s); + overflow-y: auto; +} + +.section { + display: grid; + gap: var(--sp-s); + + h3, + p { + margin: 0; + color: var(--color-foreground-secondary); + } +} + +.section-title { + display: flex; + align-items: center; + justify-content: space-between; +} + +.list { + display: grid; + gap: var(--sp-xxs); + margin: 0; + padding: 0; + list-style: none; +} + +.file-item { + display: flex; + align-items: center; + gap: var(--sp-xxs); +} + +.file-row { + display: flex; + align-items: center; + gap: var(--sp-s); + width: 100%; + padding: var(--sp-s); + border: none; + border-radius: $br-4; + background: var(--color-background-tertiary); + color: var(--color-foreground-primary); + text-align: start; + cursor: pointer; + + &:hover { + background: var(--color-background-quaternary); + } + + &.current { + cursor: default; + outline: $b-1 solid var(--color-accent-primary); + } +} + +.name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-foreground-primary); +} + +.muted { + color: var(--color-foreground-secondary); +} + +.status { + font-size: px2rem(11); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.status-core { + color: var(--color-accent-primary); +} + +.status-open { + color: var(--color-accent-success); +} + +.status-merged { + color: var(--color-foreground-secondary); +} + +.create { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--sp-s); + align-items: center; +} + +.change { + display: grid; + gap: var(--sp-xxs); + padding: var(--sp-xs) var(--sp-s); + border-radius: $br-4; + background: var(--color-background-tertiary); +} + +.change-title { + display: flex; + align-items: center; + gap: var(--sp-s); + min-width: 0; +} + +.op { + font-size: px2rem(11); + text-transform: uppercase; +} + +.op-add { + color: var(--color-accent-success); +} + +.op-del { + color: var(--color-accent-error); +} + +.op-mod { + color: var(--color-accent-warning); +} + +.conflict { + display: grid; + gap: var(--sp-xs); + padding: var(--sp-s); + border: $b-1 solid var(--color-accent-warning); + border-radius: $br-4; +} + +.values { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--sp-xxs) var(--sp-s); + margin: 0; + font-family: monospace; + font-size: px2rem(11); + word-break: break-all; + + dd { + margin: 0; + } +} + +.merge { + justify-self: stretch; +} + +.excluded { + opacity: 0.5; +} + +.group { + display: grid; + gap: var(--sp-xs); +} + +.group-title { + display: flex; + align-items: center; + gap: var(--sp-s); + min-width: 0; + font-weight: 600; +} diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 5a64cf76c9..6d82a7dc53 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -40,6 +40,7 @@ [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks.resize :as r] + [app.main.ui.workspace.branches :as branches] [app.plugins.register :as preg] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -628,6 +629,16 @@ (when (kbd/enter? event) (on-show-version-history event)))) + on-show-branches + branches/toggle-panel + + on-show-branches-key-down + (mf/use-fn + (mf/deps on-show-branches) + (fn [event] + (when (kbd/enter? event) + (on-show-branches event)))) + on-pin-version (mf/use-fn (fn [_] @@ -718,6 +729,13 @@ (tr "dashboard.show-version-history")] [:> shortcuts* {:id :toggle-history}]] + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) + :on-click on-show-branches + :on-key-down on-show-branches-key-down + :id "file-menu-design-branches"} + [:span {:class (stl/css :item-name)} + "Design branches"]] + [:div {:class (stl/css :separator)}]]) [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index 28233e3204..f9ad3d9d0c 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -21,10 +21,11 @@ [app.main.ui.context :as ctx] [app.main.ui.dashboard.team] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.exports.assets :refer [progress-widget]] [app.main.ui.formats :as fmt] [app.main.ui.icons :as deprecated-icon] + [app.main.ui.workspace.branches :as branches] [app.main.ui.workspace.presence :refer [active-sessions*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -183,7 +184,8 @@ (st/emit! :interrupt (dw/clear-edition-mode))) - (st/emit! (-> (dwh/initialize-history) + (st/emit! (dw/remove-layout-flag :design-branches) + (-> (dwh/initialize-history) (vary-meta assoc ::ev/origin "workspace-header"))))) open-share-dialog @@ -236,6 +238,16 @@ :on-click toggle-history} deprecated-icon/history]]) + (when-not ^boolean read-only? + [:div {:class (stl/css :history-section)} + [:button + {:title "Design branches" + :aria-label "Design branches" + :class (stl/css-case :selected (contains? layout :design-branches) + :history-button true) + :on-click branches/toggle-panel} + [:> icon* {:icon-id i/merge-nodes}]]]) + (when display-share-button? [:a {:class (stl/css :viewer-btn) :title (tr "workspace.header.share") diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index c8a0ece18e..dc76301925 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -29,6 +29,7 @@ [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] [app.main.ui.hooks :as hooks] [app.main.ui.hooks.resize :refer [use-resize-hook]] + [app.main.ui.workspace.branches :refer [branches-panel*]] [app.main.ui.workspace.comments :refer [comments-sidebar*]] [app.main.ui.workspace.left-header :refer [left-header*]] [app.main.ui.workspace.right-header :refer [right-header*]] @@ -286,6 +287,7 @@ [{:keys [layout section file-id page-id drawing-tool active-tokens] :as props}] (let [is-comments? (= drawing-tool :comments) is-history? (contains? layout :document-history) + is-branches? (contains? layout :design-branches) is-inspect? (= section :inspect) is-debug? (= section :debug) @@ -295,6 +297,7 @@ can-be-expanded? (and (not is-comments?) (not is-history?) + (not is-branches?) is-inspect? (= current-section :code)) @@ -355,6 +358,9 @@ is-history? [:> history-content* {}] + is-branches? + [:> branches-panel* {:file-id file-id}] + :else (let [props (mf/spread-props props {:on-change-section on-change-section