🚧 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:
Elena Torro 2026-10-02 12:45:15 +02:00
parent 09ffcb1fbc
commit 2feb570d3f
18 changed files with 1622 additions and 9 deletions

View File

@ -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]

View File

@ -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);

View File

@ -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

View File

@ -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

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

View File

@ -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

View File

@ -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

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

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

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

View 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

View File

@ -20,6 +20,7 @@
:comments
:assets
:document-history
:design-branches
:hide-palettes
:colorpalette
:element-options

View File

@ -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]

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

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

View File

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

View File

@ -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")

View File

@ -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