mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🚧 Add design branches with three-way merge (PoC)
Proof of concept, not meant to merge as is. A file can be branched into an identity-preserving copy, edited, and merged back. - common: app.common.files.merge does an attribute-level three-way merge (shape attrs grouped by ctk/sync-attrs), reports conflicts, and supports excluding changes for selective merges. - backend: file_branch table and create, list, diff, merge and delete RPCs. Branches reuse duplicate-file, which keeps shape and component ids. A merge snapshots the core file first. - frontend: branches panel in the right sidebar (list, open, create, delete, review, resolve, selective merge). The dashboard hides branch files and shows a branch count on the core file. Not done: feature flag, i18n, keep-both resolution, component touched rules, branch media relinking.
This commit is contained in:
parent
09ffcb1fbc
commit
2feb570d3f
@ -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]
|
||||
|
||||
@ -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);
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
263
backend/src/app/rpc/commands/files_branch.clj
Normal file
263
backend/src/app/rpc/commands/files_branch.clj
Normal file
@ -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)}))
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
184
backend/test/backend_tests/rpc_file_branch_test.clj
Normal file
184
backend/test/backend_tests/rpc_file_branch_test.clj
Normal file
@ -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))))
|
||||
389
common/src/app/common/files/merge.cljc
Normal file
389
common/src/app/common/files/merge.cljc
Normal file
@ -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))))))))
|
||||
134
common/test/common_tests/files/merge_test.cljc
Normal file
134
common/test/common_tests/files/merge_test.cljc
Normal file
@ -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)))
|
||||
@ -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
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
:comments
|
||||
:assets
|
||||
:document-history
|
||||
:design-branches
|
||||
:hide-palettes
|
||||
:colorpalette
|
||||
:element-options
|
||||
|
||||
@ -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]
|
||||
|
||||
349
frontend/src/app/main/ui/workspace/branches.cljs
Normal file
349
frontend/src/app/main/ui/workspace/branches.cljs
Normal file
@ -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}])])]))
|
||||
207
frontend/src/app/main/ui/workspace/branches.scss
Normal file
207
frontend/src/app/main/ui/workspace/branches.scss
Normal file
@ -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;
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user