diff --git a/backend/resources/app/email/review-request/en.html b/backend/resources/app/email/review-request/en.html
new file mode 100644
index 0000000000..a9f1651d5a
--- /dev/null
+++ b/backend/resources/app/email/review-request/en.html
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ Hello {{name|abbreviate:25}}!
+ |
+
+
+ |
+
+ {{ source-user }} has requested your review on the pull request "{{ pull-request-title }}" ({{ pull-request-route }}).
+ |
+
+
+ |
+
+ {{ pull-request-description }}
+
+ |
+
+
+ |
+
+ |
+
+
+ |
+
+ The Penpot team.
+ |
+
+
+
+
+ |
+
+
+
+
+
+ {% include "app/email/includes/footer.html" %}
+
+
+
+
+
diff --git a/backend/resources/app/email/review-request/en.subj b/backend/resources/app/email/review-request/en.subj
new file mode 100644
index 0000000000..c90e5f267d
--- /dev/null
+++ b/backend/resources/app/email/review-request/en.subj
@@ -0,0 +1 @@
+Review requested
diff --git a/backend/resources/app/email/review-request/en.txt b/backend/resources/app/email/review-request/en.txt
new file mode 100644
index 0000000000..84e12bb126
--- /dev/null
+++ b/backend/resources/app/email/review-request/en.txt
@@ -0,0 +1,13 @@
+Hello {{name|abbreviate:25}}!
+
+{{ source-user }} has requested your review on the pull request "{{ pull-request-title }}" ({{ pull-request-route }}).
+
+--
+
+{{ pull-request-description }}
+
+--
+
+{{ pull-request-url }}
+
+The Penpot team.
diff --git a/backend/scripts/_env b/backend/scripts/_env
index 4f7c5655bf..df51980474 100644
--- a/backend/scripts/_env
+++ b/backend/scripts/_env
@@ -64,6 +64,7 @@ export PENPOT_FLAGS="\
enable-file-schema-validation \
enable-redis-cache \
enable-link-preview \
+ enable-branching \
enable-subscriptions";
# Uncomment for nexus integration testing
diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj
index 2909e35815..941e0d2103 100644
--- a/backend/src/app/binfile/common.clj
+++ b/backend/src/app/binfile/common.clj
@@ -617,7 +617,8 @@
(def ^:private sql:get-project-files
"SELECT f.id FROM file AS f
WHERE f.project_id = ?
- AND f.deleted_at IS NULL")
+ AND f.deleted_at IS NULL
+ AND f.is_branch IS FALSE")
(defn get-project-files
"Get a set of file ids for the project"
diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj
index 10a97b7bee..40b83e3ac6 100644
--- a/backend/src/app/config.clj
+++ b/backend/src/app/config.clj
@@ -235,6 +235,9 @@
[:quotes-comments-per-file {:optional true} ::sm/int]
[:quotes-snapshots-per-file {:optional true} ::sm/int]
[:quotes-snapshots-per-team {:optional true} ::sm/int]
+ [:quotes-branches-per-file {:optional true} ::sm/int]
+ [:quotes-branches-per-team {:optional true} ::sm/int]
+ [:quotes-pull-requests-per-team {:optional true} ::sm/int]
[:quotes-team-access-requests-per-team {:optional true} ::sm/int]
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj
index bae1d22075..1213018437 100644
--- a/backend/src/app/email.clj
+++ b/backend/src/app/email.clj
@@ -554,6 +554,22 @@
:id ::request-team-access
:schema schema:request-team-access))
+(def ^:private schema:review-request
+ [:map
+ [:name ::sm/text]
+ [:source-user ::sm/text]
+ [:pull-request-title ::sm/text]
+ [:pull-request-route ::sm/text]
+ ;; plain :string on purpose: the description is optional and may be empty
+ [:pull-request-description [:string {:max 4000}]]
+ [:pull-request-url ::sm/text]])
+
+(def review-request
+ "Email sent to the requested reviewers of a pull request."
+ (template-factory
+ :id ::review-request
+ :schema schema:review-request))
+
(def ^:private schema:comment-mention
[:map
[:name ::sm/text]
diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj
index 1a4f039f59..69324844c8 100644
--- a/backend/src/app/migrations.clj
+++ b/backend/src/app/migrations.clj
@@ -517,7 +517,13 @@
: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 "0155-add-file-branch-table"
+ :fn (mg/resource "app/migrations/sql/0155-add-file-branch-table.sql")}
+
+ {:name "0156-add-file-pull-request-tables"
+ :fn (mg/resource "app/migrations/sql/0156-add-file-pull-request-tables.sql")}])
(defn apply-migrations!
[pool name migrations]
diff --git a/backend/src/app/migrations/sql/0155-add-file-branch-table.sql b/backend/src/app/migrations/sql/0155-add-file-branch-table.sql
new file mode 100644
index 0000000000..3e71aef6f3
--- /dev/null
+++ b/backend/src/app/migrations/sql/0155-add-file-branch-table.sql
@@ -0,0 +1,58 @@
+-- Idempotent on purpose: this migration was originally registered as
+-- "0150-add-file-branch-table" / "0152-add-file-branch-table" during
+-- development, so instances that already applied it under an old name
+-- re-apply this one safely (and pick up the later-added
+-- base_branch_revn column).
+
+ALTER TABLE file
+ ADD COLUMN IF NOT EXISTS is_branch boolean NOT NULL DEFAULT false;
+
+CREATE TABLE IF NOT EXISTS file_branch (
+ id uuid PRIMARY KEY,
+
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+
+ branch_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
+ source_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
+
+ base_snapshot_id uuid NULL,
+
+ -- main's revn when the base was (re)positioned
+ base_revn bigint NOT NULL,
+ -- the BRANCH file's revn at the same moment; the two files carry
+ -- independent revn counters, so each side's "did it move?" gate must
+ -- compare against its own counter
+ base_branch_revn bigint NOT NULL DEFAULT 0,
+
+ created_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
+
+ name text NOT NULL,
+ description text NULL,
+
+ status text NOT NULL DEFAULT 'open'
+ CHECK (status IN ('open', 'merged', 'archived')),
+
+ merged_at timestamptz NULL,
+ merged_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
+ deleted_at timestamptz NULL,
+
+ CHECK (branch_file_id != source_file_id)
+);
+
+-- upgrade path from the 0150 development shape
+ALTER TABLE file_branch
+ ADD COLUMN IF NOT EXISTS base_branch_revn bigint NOT NULL DEFAULT 0;
+
+-- backfill: at creation both counters coincide, so base_revn is the best
+-- available approximation for rows created before the column existed
+UPDATE file_branch SET base_branch_revn = base_revn WHERE base_branch_revn = 0;
+
+CREATE INDEX IF NOT EXISTS file_branch__source_file_id__idx
+ ON file_branch(source_file_id) WHERE deleted_at IS NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS file_branch__branch_file_id__idx
+ ON file_branch(branch_file_id);
+
+CREATE INDEX IF NOT EXISTS file_branch__base_snapshot_id__idx
+ ON file_branch(base_snapshot_id) WHERE base_snapshot_id IS NOT NULL;
diff --git a/backend/src/app/migrations/sql/0156-add-file-pull-request-tables.sql b/backend/src/app/migrations/sql/0156-add-file-pull-request-tables.sql
new file mode 100644
index 0000000000..a0a5f01095
--- /dev/null
+++ b/backend/src/app/migrations/sql/0156-add-file-pull-request-tables.sql
@@ -0,0 +1,65 @@
+CREATE TABLE IF NOT EXISTS file_pull_request (
+ id uuid PRIMARY KEY,
+
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+
+ file_branch_id uuid NOT NULL REFERENCES file_branch(id) ON DELETE CASCADE DEFERRABLE,
+
+ -- denormalized from file_branch: source is the BRANCH file, target is main
+ source_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
+ target_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
+
+ title text NOT NULL,
+ description text NULL,
+
+ created_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
+
+ status text NOT NULL DEFAULT 'open'
+ CHECK (status IN ('open', 'merged', 'closed')),
+
+ -- pinned snapshot of the BRANCH file that reviewers see
+ review_snapshot_id uuid NULL,
+ -- the branch file's revn when the review snapshot was (re)positioned;
+ -- branch_revn > review_revn is the cheap "outdated" gate
+ review_revn bigint NOT NULL DEFAULT 0,
+ review_updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+
+ closed_at timestamptz NULL,
+ closed_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
+ deleted_at timestamptz NULL,
+
+ CHECK (source_file_id != target_file_id)
+);
+
+-- at most one live pull request per branch
+CREATE UNIQUE INDEX IF NOT EXISTS file_pull_request__file_branch_id__open__idx
+ ON file_pull_request(file_branch_id)
+ WHERE status = 'open' AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS file_pull_request__target_file_id__idx
+ ON file_pull_request(target_file_id) WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS file_pull_request__review_snapshot_id__idx
+ ON file_pull_request(review_snapshot_id) WHERE review_snapshot_id IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS file_pull_request_review (
+ pull_request_id uuid NOT NULL REFERENCES file_pull_request(id) ON DELETE CASCADE DEFERRABLE,
+ profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
+
+ state text NOT NULL DEFAULT 'pending'
+ CHECK (state IN ('pending', 'approved', 'changes-requested')),
+
+ -- review_revn of the pull request when the verdict was submitted; a
+ -- verdict is stale when it no longer matches the pr's review_revn
+ reviewed_revn bigint NULL,
+ comment text NULL,
+
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+
+ PRIMARY KEY (pull_request_id, profile_id)
+);
+
+CREATE INDEX IF NOT EXISTS file_pull_request_review__profile_id__idx
+ ON file_pull_request_review(profile_id);
diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj
index cf80790b2e..302dedb3ff 100644
--- a/backend/src/app/rpc.clj
+++ b/backend/src/app/rpc.clj
@@ -378,6 +378,8 @@
'app.rpc.commands.files-share
'app.rpc.commands.files-update
'app.rpc.commands.files-snapshot
+ 'app.rpc.commands.files-branch
+ 'app.rpc.commands.files-pull-request
'app.rpc.commands.files-thumbnails
'app.rpc.commands.ldap
'app.rpc.commands.management
diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj
index 63e116391b..49ef3af864 100644
--- a/backend/src/app/rpc/commands/files.clj
+++ b/backend/src/app/rpc/commands/files.clj
@@ -282,7 +282,11 @@
f.vern,
f.is_shared,
ft.media_id AS thumbnail_id,
- p.team_id
+ p.team_id,
+ (select count(*) from file_branch as fb
+ where fb.source_file_id = f.id
+ and fb.deleted_at is null
+ and fb.status = 'open') as branches_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 +294,7 @@
and ft.deleted_at is null)
where f.project_id = ?
and f.deleted_at is null
+ and f.is_branch is false
order by f.modified_at desc")
(defn get-project-files
@@ -776,6 +781,10 @@
f.name,
f.is_shared,
ft.media_id AS thumbnail_id,
+ (select count(*) from file_branch as fb
+ where fb.source_file_id = f.id
+ and fb.deleted_at is null
+ and fb.status = 'open') as branches_count,
row_number() over w as row_num,
p.team_id
from file as f
@@ -786,6 +795,7 @@
where p.team_id = ?
and p.deleted_at is null
and f.deleted_at is null
+ and f.is_branch is false
window w as (partition by f.project_id order by f.modified_at desc)
order by f.modified_at desc
)
@@ -830,6 +840,7 @@
WHERE p.team_id = ?
AND (p.deleted_at > ?::timestamptz OR
f.deleted_at > ?::timestamptz)
+ AND f.is_branch IS FALSE
WINDOW w AS (PARTITION BY f.project_id
ORDER BY f.modified_at DESC)
ORDER BY f.modified_at DESC
@@ -1307,6 +1318,7 @@
JOIN team AS t ON (t.id = p.team_id)
WHERE t.deleted_at IS NULL
AND t.id = ?
+ AND f.is_branch IS FALSE
AND f.id = ANY(?::uuid[])")
(def ^:private sql:restore-files
diff --git a/backend/src/app/rpc/commands/files_branch.clj b/backend/src/app/rpc/commands/files_branch.clj
new file mode 100644
index 0000000000..b8f03bee38
--- /dev/null
+++ b/backend/src/app/rpc/commands/files_branch.clj
@@ -0,0 +1,1028 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns app.rpc.commands.files-branch
+ "RPC commands for file branching: create an isolated copy of a file
+ (a \"branch\") linked to its source file (\"main\"), list branches,
+ diff a branch against main, merge a branch into main (with conflict
+ resolutions), and update a branch from main. See
+ `app.rpc.commands.files-snapshot` for the patterns this namespace
+ mirrors."
+ (:require
+ [app.binfile.common :as bfc]
+ [app.common.exceptions :as ex]
+ [app.common.features :as cfeat]
+ [app.common.files.branch-merge :as bm]
+ [app.common.files.changes :as cpc]
+ [app.common.files.helpers :as cfh]
+ [app.common.files.repair :as cfr]
+ [app.common.files.validate :as cfv]
+ [app.common.logging :as l]
+ [app.common.schema :as sm]
+ [app.common.time :as ct]
+ [app.common.uuid :as uuid]
+ [app.config :as cf]
+ [app.db :as db]
+ [app.features.fdata :as fdata]
+ [app.features.file-snapshots :as fsnap]
+ [app.features.logical-deletion :as ldel]
+ [app.loggers.webhooks :as-alias webhooks]
+ [app.msgbus :as mbus]
+ [app.rpc :as-alias rpc]
+ [app.rpc.climit :as-alias climit]
+ [app.rpc.commands.files :as files]
+ [app.rpc.commands.files-update :as fupd]
+ [app.rpc.commands.management :as mgmt]
+ [app.rpc.commands.teams :as teams]
+ [app.rpc.doc :as-alias doc]
+ [app.rpc.quotes :as quotes]
+ [app.util.blob :as blob]
+ [app.util.pointer-map :as pmap]
+ [app.util.services :as sv]
+ [app.worker :as wrk]
+ [clojure.string :as str]))
+
+(defn- check-branching-enabled!
+ "Guard the branching commands behind the `:branching` product flag."
+ []
+ (when-not (contains? cf/flags :branching)
+ (ex/raise :type :restriction
+ :code :branching-disabled
+ :hint "the branching feature is not enabled on this instance")))
+
+;; --- Helpers: media pairing
+;;
+;; `duplicate-file` (branch creation) gives the branch's file_media_object
+;; rows FRESH ids and relinks the branch `:data` to them, so the same image
+;; carries a different id on each side. Before diffing or merging, the
+;; branch-side media ids must be normalized back to the target's ids
+;; (together with the file id, via `bm/remap-refs`); otherwise every image
+;; looks changed, and merged shapes would reference media rows owned by the
+;; branch file — which break when the branch is deleted and GC'd.
+
+(def ^:private sql:file-media-identity
+ "SELECT id, media_id, name, width, height, mtype
+ FROM file_media_object
+ WHERE file_id = ?
+ AND deleted_at IS NULL")
+
+(defn- media-pairs
+ "{from-media-id -> to-media-id} for the file_media_object rows present on
+ both files with the same content identity (storage object + name +
+ dimensions + mtype) — i.e. the rows `duplicate-file` copied at branch
+ creation. Rows with several identical copies are paired positionally
+ (they are interchangeable). Media added on one side stays unpaired."
+ [cfg from-file-id to-file-id]
+ (let [ident (juxt :media-id :name :width :height :mtype)
+ from-gs (group-by ident (db/exec! cfg [sql:file-media-identity from-file-id]))
+ to-gs (group-by ident (db/exec! cfg [sql:file-media-identity to-file-id]))]
+ (reduce-kv (fn [acc k from-group]
+ (if-let [to-group (get to-gs k)]
+ (into acc (map (fn [f t] [(:id f) (:id t)])
+ (sort-by :id from-group)
+ (sort-by :id to-group)))
+ acc))
+ {}
+ from-gs)))
+
+(def ^:private sql:media-rows-by-id
+ "SELECT * FROM file_media_object WHERE id = ANY(?) AND deleted_at IS NULL")
+
+(defn- unpaired-media-rows
+ "Full file_media_object rows of `file-id` that are used by `data` but have
+ no counterpart in `pairs` — media added on this side since branching.
+ They must be copied into the target file when the merge/update applies."
+ [cfg file-id data pairs]
+ (let [used (into #{} (remove pairs) (cfh/collect-used-media data))]
+ (if (seq used)
+ (->> (db/exec! cfg [sql:media-rows-by-id
+ (db/create-array (db/get-connection cfg) "uuid" used)])
+ (filterv #(= file-id (:file-id %))))
+ [])))
+
+(defn- copy-media-rows!
+ "Insert copies of media `rows` into `file-id`, with the ids assigned in
+ `fresh-map` ({old-id -> new-id})."
+ [conn file-id fresh-map rows]
+ (doseq [row rows]
+ (db/insert! conn :file-media-object
+ (-> row
+ (assoc :id (get fresh-map (:id row)))
+ (assoc :file-id file-id)
+ (dissoc :created-at :deleted-at))
+ {::db/return-keys false})))
+
+;; --- Helpers: merge base snapshot
+
+(defn- get-base-data
+ "The merge-base `:data` from the branch's base snapshot. Raises when the
+ snapshot cannot be resolved: silently falling back to main would degrade
+ the three-way merge into \"branch overwrites main\" without conflicts."
+ [cfg {:keys [source-file-id base-snapshot-id] :as branch}]
+ (when-not base-snapshot-id
+ (ex/raise :type :not-found
+ :code :base-snapshot-missing
+ :hint "the branch has no merge-base snapshot"
+ :branch-id (:id branch)))
+ (:data (fsnap/get-snapshot cfg source-file-id base-snapshot-id)))
+
+(defn- release-base-snapshot!
+ "Reschedule a branch's base snapshot for normal deletion: it is pinned
+ ~10 years while the branch is open, and must be released when the branch
+ is merged, deleted or its base is repositioned."
+ [cfg {:keys [base-snapshot-id source-file-id]} deleted-at]
+ (when base-snapshot-id
+ (fsnap/delete! cfg
+ :id base-snapshot-id
+ :file-id source-file-id
+ :deleted-at deleted-at)))
+
+;; --- Helpers: pull requests attached to a branch
+;;
+;; Pull requests live in `app.rpc.commands.files-pull-request`, which
+;; requires THIS namespace (it reuses the diff-count helpers), so the
+;; branch lifecycle hooks below work over the table directly to avoid a
+;; circular dependency.
+
+(def ^:private sql:get-open-branch-pull-requests
+ "SELECT id, source_file_id, review_snapshot_id
+ FROM file_pull_request
+ WHERE file_branch_id = ?
+ AND status = 'open'
+ AND deleted_at IS NULL")
+
+(defn- close-branch-pull-requests!
+ "Close (or mark merged) any open pull request attached to a branch and
+ release its pinned review snapshot: the review sandbox must not
+ outlive the branch. The pull request row itself is kept as history.
+ Must run inside a transaction."
+ [{:keys [::db/conn] :as cfg} branch-id {:keys [profile-id status deleted-at]}]
+ (doseq [pr (db/exec! conn [sql:get-open-branch-pull-requests branch-id])]
+ (let [ts (ct/now)]
+ (db/update! conn :file-pull-request
+ {:status (or status "closed")
+ :closed-at ts
+ :closed-by profile-id
+ :updated-at ts}
+ {:id (:id pr)}
+ {::db/return-keys false})
+ (when-let [snapshot-id (:review-snapshot-id pr)]
+ (fsnap/delete! cfg
+ :id snapshot-id
+ :file-id (:source-file-id pr)
+ :deleted-at deleted-at)))))
+
+;; --- Helpers: branch deletion (shared by delete-file-branch and merge)
+
+(defn- delete-branch!
+ "Logically delete a branch and its branch file (with the team's deletion
+ delay), release the base snapshot, schedule the file object GC and notify
+ the branch file's open clients. Must run inside a transaction."
+ [{:keys [::mbus/msgbus ::db/conn] :as cfg}
+ {:keys [id branch-file-id] :as branch}
+ {:keys [profile-id session-id status]}]
+ (let [team (teams/get-team conn :profile-id profile-id :file-id branch-file-id)
+ dt (ct/in-future (ldel/get-deletion-delay team))]
+ (db/update! conn :file {:deleted-at dt} {:id branch-file-id} {::db/return-keys false})
+ (db/delete! conn :file-library-rel {:library-file-id branch-file-id})
+ (db/update! conn :file-branch
+ (cond-> {:deleted-at dt :updated-at (ct/now)}
+ (some? status) (assoc :status status))
+ {:id id}
+ {::db/return-keys false})
+ (release-base-snapshot! cfg branch dt)
+ ;; the review sandbox must not outlive the branch (`status` above is
+ ;; the BRANCH status; a pull request closed this way is just "closed")
+ (close-branch-pull-requests! cfg id {:profile-id profile-id
+ :status "closed"
+ :deleted-at dt})
+ (wrk/submit! {::db/conn conn
+ ::wrk/task :delete-object
+ ::wrk/params {:object :file :deleted-at dt :id branch-file-id}})
+ (mbus/pub! msgbus
+ :topic branch-file-id
+ :message {:type :file-deleted
+ :file-id branch-file-id
+ :profile-id profile-id
+ :session-id session-id})
+ nil))
+
+;; --- COMMAND: create-file-branch
+
+(def ^:private schema:create-file-branch
+ [:map {:title "create-file-branch"}
+ [:file-id ::sm/uuid]
+ [:name [:string {:max 250}]]
+ [:description {:optional true} [:string {:max 4000}]]])
+
+(sv/defmethod ::create-file-branch
+ "Create a branch from a file. The branch is a full, isolated copy of
+ the source file (created through the same pipeline as duplicate-file,
+ which preserves the internal `:data` ids) plus a system snapshot of
+ main captured as the merge base."
+ {::doc/added "2.16"
+ ::webhooks/event? true
+ ::sm/params schema:create-file-branch
+ ::climit/id [[:create-file-branch/by-profile ::rpc/profile-id]
+ [:create-file-branch/global]]}
+ [cfg {:keys [::rpc/profile-id file-id name description]}]
+ (check-branching-enabled!)
+ (files/check-edition-permissions! cfg profile-id file-id)
+
+ (let [file-row (db/get-by-id cfg :file file-id)
+ project (db/get-by-id cfg :project (:project-id file-row))]
+
+ (when (:is-branch file-row)
+ (ex/raise :type :validation
+ :code :cannot-branch-a-branch
+ :hint "branches of branches are not supported"
+ :file-id file-id))
+
+ (-> cfg
+ (assoc ::quotes/profile-id profile-id)
+ (assoc ::quotes/project-id (:project-id file-row))
+ (assoc ::quotes/team-id (:team-id project))
+ (assoc ::quotes/file-id file-id)
+ (quotes/check! {::quotes/id ::quotes/branches-per-file}
+ {::quotes/id ::quotes/branches-per-team}))
+
+ (db/tx-run! cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ (db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
+
+ ;; Serialize against concurrent update-file on main (same
+ ;; advisory lock) so the base snapshot and the duplicated
+ ;; copy are taken from the SAME state of the file — a
+ ;; concurrent save between them would make the base differ
+ ;; from the branch's initial content and manufacture
+ ;; phantom diffs later.
+ (db/xact-lock! conn file-id)
+
+ ;; 1. Materialize the merge base: a system snapshot of
+ ;; main, kept long-lived so it is not pruned by the
+ ;; snapshot GC while the branch is open (it is released
+ ;; when the branch is merged/deleted or its base moves).
+ (let [file (bfc/get-file cfg file-id :realize? true)
+ base (fsnap/create! cfg file
+ {:label (str "branch-base/" name)
+ :profile-id profile-id
+ :created-by "system"
+ :deleted-at (ct/in-future {:days 3650})})
+
+ ;; 2. Duplicate main into a new branch file. The
+ ;; duplicate pipeline remaps the file/media/library
+ ;; ids but preserves the internal :data ids, which
+ ;; is the precondition for an id-based merge.
+ branch-id (uuid/next)
+ branch (binding [bfc/*state* (volatile! {:index {file-id branch-id}})]
+ (mgmt/duplicate-file
+ (assoc cfg ::bfc/timestamp (ct/now))
+ {:profile-id profile-id
+ :file-id file-id
+ :name name
+ :reset-shared-flag true}))
+
+ meta-id (uuid/next)]
+
+ ;; 3. Mark the new file row as a branch so it is hidden
+ ;; from the project/team file listings.
+ (db/update! conn :file
+ {:is-branch true}
+ {:id (:id branch)}
+ {::db/return-keys false})
+
+ ;; 4. Persist the branch metadata. `base-revn` tracks
+ ;; MAIN's revision counter and `base-branch-revn` the
+ ;; BRANCH file's one (they coincide at creation but
+ ;; drift apart: they are independent counters).
+ (db/insert! conn :file-branch
+ {:id meta-id
+ :branch-file-id (:id branch)
+ :source-file-id file-id
+ :base-snapshot-id (:id base)
+ :base-revn (:revn file)
+ :base-branch-revn (:revn file)
+ :created-by profile-id
+ :name name
+ :description description
+ :status "open"}
+ {::db/return-keys false})
+
+ {:id meta-id
+ :branch-file-id (:id branch)
+ :source-file-id file-id
+ :base-revn (:revn file)
+ :name name
+ :description description
+ :status "open"})))))
+
+;; --- COMMAND QUERY: get-file-branches
+
+(def ^:private schema:get-file-branches
+ [:map {:title "get-file-branches"}
+ [:file-id ::sm/uuid]
+ [:include-archived {:optional true} ::sm/boolean]])
+
+(def ^:private sql:get-file-branches
+ "SELECT fb.id,
+ fb.branch_file_id,
+ fb.source_file_id,
+ fb.name,
+ fb.description,
+ fb.status,
+ fb.created_by,
+ fb.created_at,
+ fb.updated_at,
+ fb.merged_at,
+ fb.merged_by,
+ fb.base_revn,
+ fb.base_branch_revn,
+ fb.base_snapshot_id,
+ bf.revn AS branch_revn,
+ sf.revn AS source_revn,
+ sf.name AS source_name
+ FROM file_branch AS fb
+ JOIN file AS bf ON (bf.id = fb.branch_file_id)
+ JOIN file AS sf ON (sf.id = fb.source_file_id)
+ WHERE fb.source_file_id = ?
+ AND fb.deleted_at IS NULL
+ AND (?::boolean OR fb.status = 'open')
+ ORDER BY fb.created_at DESC")
+
+(defn revn-deltas
+ "Cheap `[ahead-revn behind-revn]` deltas gating the expensive diff. Each
+ side is compared against ITS OWN counter captured when the base was
+ (re)positioned: `branch-revn` vs `base-branch-revn` and `source-revn` vs
+ `base-revn` — the two revns are independent counters and must never be
+ mixed."
+ [{:keys [branch-revn source-revn base-revn base-branch-revn]}]
+ [(max 0 (- branch-revn (or base-branch-revn base-revn)))
+ (max 0 (- source-revn base-revn))])
+
+(defn- branch-id-map
+ "Reference-normalization map for diffing/merging a branch against its
+ source: the branch file id plus the branch->source media pairs (see
+ `media-pairs`)."
+ [cfg branch-file-id source-file-id]
+ (-> (media-pairs cfg branch-file-id source-file-id)
+ (assoc branch-file-id source-file-id)))
+
+(defn branch-diff-counts
+ "Entity-level `[ahead behind conflicts]` change counts between a branch
+ file and its source (main) — the same numbers the compare dialog lists
+ (root-frame churn and structural noise filtered out). The revn deltas
+ are used only as a cheap gate to skip the (expensive) 3-way diff when a
+ side hasn't moved. `main-data` may be pre-realized and shared across
+ branches of the same source; otherwise it is loaded on demand.
+
+ When the base snapshot cannot be resolved the counts degrade to zeros
+ with a warning: this is a read-only listing, and merge/update DO refuse
+ loudly in that situation (`get-base-data`)."
+ [cfg main-data {:keys [source-file-id branch-file-id ahead-revn behind-revn] :as branch}]
+ (if (and (zero? ahead-revn) (zero? behind-revn))
+ [0 0 0]
+ (try
+ (let [main-data (or main-data (:data (bfc/get-file cfg source-file-id :realize? true)))
+ branch-data (bm/remap-refs
+ (:data (bfc/get-file cfg branch-file-id :realize? true))
+ (branch-id-map cfg branch-file-id source-file-id))
+ base-data (get-base-data cfg branch)
+ clean-count (fn [m] (let [s (:stats m)]
+ (+ (:added s) (:modified s) (:deleted s))))
+ fwd (when (pos? ahead-revn)
+ (bm/compute-merge base-data main-data branch-data :branch->main))
+ bwd (when (pos? behind-revn)
+ (bm/compute-merge base-data branch-data main-data :branch->main))]
+ [(if fwd (clean-count fwd) 0)
+ (if bwd (clean-count bwd) 0)
+ ;; conflicts are symmetric; only possible when both sides diverged
+ (if (and (pos? ahead-revn) (pos? behind-revn)) (count (:conflicts fwd)) 0)])
+ (catch Throwable cause
+ (l/wrn :hint "unable to compute branch diff counts"
+ :branch-file-id (str branch-file-id)
+ :source-file-id (str source-file-id)
+ :cause cause)
+ [0 0 0]))))
+
+(sv/defmethod ::get-file-branches
+ "List the branches of a file. `ahead`/`behind` are entity-level change
+ counts (matching the compare dialog), gated by the cheap revn deltas so
+ in-sync branches skip the diff entirely; `main` is realized once and
+ shared across branches."
+ {::doc/added "2.16"
+ ::sm/params schema:get-file-branches}
+ [cfg {:keys [::rpc/profile-id file-id include-archived]}]
+ (check-branching-enabled!)
+ (db/run! cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ (files/check-read-permissions! cfg profile-id file-id)
+ (let [rows (db/exec! conn [sql:get-file-branches file-id (boolean include-archived)])
+ ;; only OPEN branches get diff counts: merged/archived ones
+ ;; are not going to be merged as-is, so the expensive diff
+ ;; would be wasted work (and their base may be released)
+ open? (fn [row] (= "open" (:status row)))
+ ;; realize main once, only if some open branch diverged
+ need? (some (fn [r] (and (open? r)
+ (let [[a b] (revn-deltas r)] (or (pos? a) (pos? b)))))
+ rows)
+ main-data (when need? (:data (bfc/get-file cfg file-id :realize? true)))]
+ (mapv (fn [{:keys [base-snapshot-id branch-file-id] :as row}]
+ (let [[ahead-revn behind-revn] (revn-deltas row)
+ [ahead behind conflicts]
+ (if (open? row)
+ (branch-diff-counts cfg main-data
+ {:id (:id row)
+ :source-file-id file-id
+ :branch-file-id branch-file-id
+ :base-snapshot-id base-snapshot-id
+ :ahead-revn ahead-revn
+ :behind-revn behind-revn})
+ [0 0 0])]
+ (-> row
+ (assoc :ahead ahead :behind behind :conflicts conflicts)
+ (dissoc :branch-revn :source-revn :base-snapshot-id :base-branch-revn))))
+ rows)))))
+
+;; --- COMMAND QUERY: get-branch-diff
+
+(def ^:private schema:get-branch-diff
+ [:map {:title "get-branch-diff"}
+ [:branch-id ::sm/uuid]
+ ;; `:branch->main` (default) = the branch's outgoing changes to merge;
+ ;; `:main->branch` = main's incoming changes the branch is missing.
+ [:direction {:optional true} [:enum :branch->main :main->branch]]])
+
+(sv/defmethod ::get-branch-diff
+ "Read-only three-way diff between a branch and its source (main),
+ using the merge base captured at branch creation. Returns the summary
+ produced by `branch-merge/compute-merge` (stats, changes, conflicts).
+
+ NOTE (Phase 2): base/main/branch are assumed to share the same file
+ data version; explicit migration normalization before diffing is a
+ later refinement."
+ {::doc/added "2.16"
+ ::sm/params schema:get-branch-diff
+ ::db/transaction true}
+ [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id branch-id direction]}]
+ (check-branching-enabled!)
+ (let [branch (db/get* conn :file-branch {:id branch-id})]
+ (when (or (nil? branch) (some? (:deleted-at branch)))
+ (ex/raise :type :not-found
+ :code :branch-not-found
+ :hint "unable to find branch with the provided id"
+ :branch-id branch-id))
+
+ (files/check-read-permissions! cfg profile-id (:source-file-id branch))
+
+ (let [main-file (bfc/get-file cfg (:source-file-id branch) :realize? true)
+ branch-file (bfc/get-file cfg (:branch-file-id branch) :realize? true)
+ main-data (:data main-file)
+ branch-data (bm/remap-refs
+ (:data branch-file)
+ (branch-id-map cfg (:branch-file-id branch) (:source-file-id branch)))
+ base-data (get-base-data cfg branch)]
+ ;; `:meta` carries the "when" of each side so the resolution UI can show
+ ;; how recent main/branch are (base is pinned at branch creation), plus
+ ;; `:main-revn` so the client can do optimistic concurrency on merge
+ ;; (`expected-main-revn`). The last editor's identity is intentionally
+ ;; omitted: files do not store a reliable "modified-by".
+ (-> (bm/compute-merge base-data main-data branch-data (or direction :branch->main))
+ (assoc :meta {:base-at (:created-at branch)
+ :main-at (:modified-at main-file)
+ :branch-at (:modified-at branch-file)
+ :main-revn (:revn main-file)})))))
+
+;; --- COMMAND: merge-file-branch
+
+(def ^:private schema:merge-file-branch
+ [:map {:title "merge-file-branch"}
+ [:branch-id ::sm/uuid]
+ ;; conflict resolutions keyed by entity id (uuid) or, for structural
+ ;; conflicts, a keyword id like :active-themes. A resolution is either a
+ ;; whole-entity choice (:main/:branch) or a per-attr map {attr -> side}.
+ [:resolutions {:optional true} [:map-of :any [:or :keyword [:map-of :keyword :keyword]]]]
+ [:expected-main-revn {:optional true} ::sm/int]
+ ;; when false (the default) the branch and its file are logically
+ ;; deleted right after a successful merge, in the SAME transaction (so
+ ;; merged copies do not pile up); when true the branch is kept as
+ ;; "merged" and can be inspected/restored from the archived list.
+ [:keep-branch {:optional true} ::sm/boolean]])
+
+(sv/defmethod ::merge-file-branch
+ "Merge a branch into its source file (main).
+
+ Phase 3 scope: clean merges only. If the three-way diff has conflicts
+ the command returns `{:status :conflicts}` (resolution UI lands in a
+ later phase); if it contains change kinds not yet translatable
+ (components, pages, tokens) it returns `{:status :unsupported}` so no
+ change is silently dropped. Otherwise it applies the merge to main
+ through the production change pipeline, takes a safety snapshot, marks
+ the branch merged and notifies open clients via msgbus."
+ {::doc/added "2.16"
+ ::webhooks/event? true
+ ::sm/params schema:merge-file-branch
+ ::climit/id [[:merge-file-branch/global]]}
+ [{:keys [::mbus/msgbus] :as cfg}
+ {:keys [::rpc/profile-id ::rpc/session-id branch-id resolutions expected-main-revn keep-branch]}]
+ (check-branching-enabled!)
+ (let [branch (db/get* cfg :file-branch {:id branch-id})]
+ (when (or (nil? branch) (some? (:deleted-at branch)))
+ (ex/raise :type :not-found
+ :code :branch-not-found
+ :branch-id branch-id))
+ (when (not= "open" (:status branch))
+ (ex/raise :type :validation
+ :code :branch-not-open
+ :branch-id branch-id))
+
+ (let [main-id (:source-file-id branch)
+ branch-file-id (:branch-file-id branch)]
+ ;; Only editors of main can integrate (same rule as Figma).
+ (files/check-edition-permissions! cfg profile-id main-id)
+
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ ;; Serialize against concurrent edits/merges on BOTH files (same
+ ;; advisory lock the normal update-file path takes; stable order
+ ;; to avoid deadlocks): without the branch lock, edits saved to
+ ;; the branch while the merge runs would be silently lost when
+ ;; the branch is marked merged (and possibly deleted) below.
+ (run! (partial db/xact-lock! conn) (sort [main-id branch-file-id]))
+
+ (let [main-file (bfc/get-file cfg main-id :realize? true)
+ branch-file (bfc/get-file cfg branch-file-id :realize? true)
+ base-data (get-base-data cfg branch)
+
+ ;; canonicalize the branch's local refs to main's ids: the
+ ;; branch file id (components, library color/typography
+ ;; refs) and the paired media ids; media ADDED on the
+ ;; branch gets fresh ids pre-allocated here — the rows are
+ ;; copied into main only if the merge actually applies.
+ pairs (media-pairs cfg branch-file-id main-id)
+ new-media (unpaired-media-rows cfg branch-file-id (:data branch-file) pairs)
+ fresh-map (into {} (map (fn [row] [(:id row) (uuid/next)])) new-media)
+ id-map (-> pairs
+ (merge fresh-map)
+ (assoc branch-file-id main-id))
+ branch-data (bm/remap-refs (:data branch-file) id-map)]
+
+ (when (and (some? expected-main-revn)
+ (not= expected-main-revn (:revn main-file)))
+ (ex/raise :type :conflict
+ :code :file-modified
+ :hint "main was modified, recompute the diff and retry"))
+
+ (let [merge-summary (bm/compute-merge base-data (:data main-file)
+ branch-data :branch->main)
+ conflicts (:conflicts merge-summary)
+ resolved? (fn [c] (bm/conflict-resolved? c (get resolutions (:id c))))
+ unresolved (remove resolved? conflicts)
+
+ finish-branch!
+ (fn [ts]
+ ;; mark merged, release the pinned base snapshot and —
+ ;; unless the user chose to keep it — delete the branch
+ ;; in this same transaction (no client-driven second
+ ;; call, no window where a crash leaves a stale copy)
+ (db/update! conn :file-branch
+ {:status "merged"
+ :merged-at ts
+ :merged-by profile-id
+ :updated-at ts}
+ {:id branch-id}
+ {::db/return-keys false})
+ (let [team (teams/get-team conn :profile-id profile-id :file-id main-id)
+ delay (ldel/get-deletion-delay team)]
+ ;; an open pull request over this branch has served
+ ;; its purpose: mark it merged and close its sandbox
+ (close-branch-pull-requests! cfg branch-id
+ {:profile-id profile-id
+ :status "merged"
+ :deleted-at (ct/in-future delay)})
+ (if keep-branch
+ (release-base-snapshot! cfg branch (ct/in-future delay))
+ (delete-branch! cfg branch {:profile-id profile-id
+ :session-id session-id}))))]
+ (cond
+ (seq unresolved)
+ {:status :conflicts :conflicts conflicts}
+
+ :else
+ (let [{:keys [changes unsupported]}
+ (bm/compute-changes base-data (:data main-file) branch-data
+ (or resolutions {})
+ merge-summary)]
+ (cond
+ (seq unsupported)
+ {:status :unsupported :kinds (vec unsupported)}
+
+ (empty? changes)
+ ;; Nothing to integrate (branch matches main): close the
+ ;; branch without touching main.
+ (let [ts (ct/now)]
+ (finish-branch! ts)
+ {:status :merged :revn (:revn main-file) :source-file-id main-id})
+
+ :else
+ (let [team (teams/get-team conn :profile-id profile-id :file-id main-id)
+ delay (ldel/get-deletion-delay team)
+ ts (ct/now)]
+ (binding [pmap/*tracked* (pmap/create-tracked)
+ pmap/*load-fn* (partial fdata/load-pointer cfg main-id)
+ cfeat/*current* (:features main-file)
+ cfeat/*previous* (:features main-file)]
+
+ ;; Safety snapshot of pre-merge main (rollback via versions).
+ (fsnap/create! cfg main-file
+ {:label (str "pre-merge/" (:name branch))
+ :created-by "system"
+ :deleted-at (ct/in-future delay)
+ :profile-id profile-id})
+
+ ;; media added on the branch: copy its rows into main
+ ;; under the pre-allocated ids the changes reference
+ (copy-media-rows! conn main-id fresh-map new-media)
+
+ (let [merged (-> main-file
+ (update :revn inc)
+ (update :data #(cpc/process-changes % changes)))
+ libs (bfc/get-resolved-file-libraries cfg merged)
+ errors (not-empty (cfv/validate-file merged libs))
+ merged (if errors
+ (update merged :data cpc/process-changes
+ (cfr/repair-file merged libs errors))
+ merged)]
+
+ ;; Change log (xlog), GC-eligible after the delay.
+ (db/insert! conn :file-change
+ {:id (uuid/next)
+ :session-id session-id
+ :profile-id profile-id
+ :created-at ts
+ :updated-at ts
+ :deleted-at (ct/in-future {:hours 1})
+ :file-id main-id
+ :revn (:revn merged)
+ :version (:version merged)
+ :features (into-array (:features merged))
+ :changes (blob/encode (vec changes))}
+ {::db/return-keys false})
+
+ (fupd/persist-file! (assoc cfg ::fupd/timestamp ts) merged)
+
+ (finish-branch! ts)
+
+ (mbus/pub! msgbus
+ :topic main-id
+ :message {:type :file-merged
+ :file-id main-id
+ :session-id session-id
+ :revn (:revn merged)})
+
+ {:status :merged :revn (:revn merged) :source-file-id main-id})))))))))))))
+
+;; --- COMMAND: update-branch-from-main
+
+(def ^:private schema:update-branch-from-main
+ [:map {:title "update-branch-from-main"}
+ [:branch-id ::sm/uuid]
+ ;; resolutions in UI terms: id -> :main (take main) | :branch (keep
+ ;; branch) | {attr -> side} (per-attr)
+ [:resolutions {:optional true} [:map-of :any [:or :keyword [:map-of :keyword :keyword]]]]])
+
+(sv/defmethod ::update-branch-from-main
+ "Bring the changes main received since the merge base into the branch
+ (the reverse direction of a merge). Returns `{:status :conflicts}` when
+ main and the branch diverged on the same entity and it is not resolved
+ in `resolutions`, or `{:status :unsupported}` for change kinds not yet
+ translatable. On success it applies main's changes (and any conflicts
+ resolved to main) to the branch, takes a safety snapshot, and
+ repositions the merge base to the current state of main."
+ {::doc/added "2.16"
+ ::webhooks/event? true
+ ::sm/params schema:update-branch-from-main
+ ::climit/id [[:update-branch-from-main/global]]}
+ [{:keys [::mbus/msgbus] :as cfg}
+ {:keys [::rpc/profile-id ::rpc/session-id branch-id resolutions]}]
+ (check-branching-enabled!)
+ (let [branch (db/get* cfg :file-branch {:id branch-id})]
+ (when (or (nil? branch) (some? (:deleted-at branch)))
+ (ex/raise :type :not-found :code :branch-not-found :branch-id branch-id))
+ (when (not= "open" (:status branch))
+ (ex/raise :type :validation :code :branch-not-open :branch-id branch-id))
+
+ (let [branch-file-id (:branch-file-id branch)
+ main-id (:source-file-id branch)]
+ ;; Editing the branch -> need edition permissions on the branch file.
+ (files/check-edition-permissions! cfg profile-id branch-file-id)
+
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ (db/xact-lock! conn branch-file-id)
+
+ (let [main-file (bfc/get-file cfg main-id :realize? true)
+ branch-file (bfc/get-file cfg branch-file-id :realize? true)
+ base-raw (get-base-data cfg branch)
+
+ team (teams/get-team conn :profile-id profile-id :file-id branch-file-id)
+ delay (ldel/get-deletion-delay team)
+
+ ;; update applies main's changes INTO the branch, so the
+ ;; canonical local ids are the branch's: re-point main/base
+ ;; local refs (file id + paired media) to the branch's ids;
+ ;; media ADDED on main gets fresh ids pre-allocated here and
+ ;; its rows copied into the branch only if the update applies.
+ pairs (media-pairs cfg main-id branch-file-id)
+ new-media (unpaired-media-rows cfg main-id (:data main-file) pairs)
+ fresh-map (into {} (map (fn [row] [(:id row) (uuid/next)])) new-media)
+ id-map (-> pairs
+ (merge fresh-map)
+ (assoc main-id branch-file-id))
+ base-data (bm/remap-refs base-raw id-map)
+ main-data (bm/remap-refs (:data main-file) id-map)
+
+ ;; did the branch carry its OWN changes before this update?
+ ;; (revn moved since the base was last positioned)
+ branch-diverged?
+ (pos? (- (:revn branch-file)
+ (or (:base-branch-revn branch) (:base-revn branch))))
+
+ reposition-base!
+ (fn [ts branch-revn]
+ (let [new-base (fsnap/create! cfg main-file
+ {:label (str "branch-base/" (:name branch))
+ :created-by "system"
+ :deleted-at (ct/in-future {:days 3650})
+ :profile-id profile-id})
+ ;; the ahead/behind revn gate compares each side's revn
+ ;; against this stored counter to SKIP the diff. After
+ ;; an update the new base equals MAIN, so a branch that
+ ;; had its own changes still differs from it even if
+ ;; its revn never moves again — keep the gate open by
+ ;; storing one revn less; the diff then reports the
+ ;; real counts.
+ branch-revn (if branch-diverged? (dec branch-revn) branch-revn)]
+ ;; the previous base is superseded: release its pin
+ (release-base-snapshot! cfg branch (ct/in-future delay))
+ (db/update! conn :file-branch
+ {:base-snapshot-id (:id new-base)
+ :base-revn (:revn main-file)
+ :base-branch-revn branch-revn
+ :updated-at ts}
+ {:id branch-id}
+ {::db/return-keys false})))
+
+ merge-summary
+ (bm/compute-merge base-data main-data
+ (:data branch-file) :main->branch)
+
+ conflicts (:conflicts merge-summary)
+ resolved? (fn [c] (bm/conflict-resolved? c (get resolutions (:id c))))
+ unresolved (remove resolved? conflicts)
+
+ ;; UI resolutions are in main/branch terms; the update applies
+ ;; main->branch (compute-changes target=branch, source=main),
+ ;; where :branch means "take the source (main)". So invert each
+ ;; side — including the per-attr maps, value by value.
+ flip (fn [v] (case v :main :branch, :branch :main, v))
+ inverted (into {} (map (fn [[k v]]
+ [k (if (map? v)
+ (update-vals v flip)
+ (flip v))]))
+ resolutions)]
+
+ (cond
+ (seq unresolved)
+ {:status :conflicts :conflicts conflicts}
+
+ :else
+ ;; target = branch, source = main -> changes that bring main's
+ ;; net changes (and conflicts resolved to main) into the branch.
+ ;; The `:main->branch` summary maps to the same (theirs, ours)
+ ;; assignment compute-changes uses internally here, so it can
+ ;; be reused for the unsupported-kinds gate without re-diffing.
+ (let [{:keys [changes unsupported]}
+ (bm/compute-changes base-data (:data branch-file) main-data
+ (or inverted {})
+ merge-summary)]
+ (cond
+ (seq unsupported)
+ {:status :unsupported :kinds (vec unsupported)}
+
+ (empty? changes)
+ (let [ts (ct/now)]
+ (reposition-base! ts (:revn branch-file))
+ {:status :updated :revn (:revn branch-file)})
+
+ :else
+ (let [ts (ct/now)]
+ (binding [pmap/*tracked* (pmap/create-tracked)
+ pmap/*load-fn* (partial fdata/load-pointer cfg branch-file-id)
+ cfeat/*current* (:features branch-file)
+ cfeat/*previous* (:features branch-file)]
+
+ (fsnap/create! cfg branch-file
+ {:label (str "pre-update/" (:name branch))
+ :created-by "system"
+ :deleted-at (ct/in-future delay)
+ :profile-id profile-id})
+
+ ;; media added on main: copy its rows into the branch
+ ;; under the pre-allocated ids the changes reference
+ (copy-media-rows! conn branch-file-id fresh-map new-media)
+
+ (let [updated (-> branch-file
+ (update :revn inc)
+ (update :data #(cpc/process-changes % changes)))
+ libs (bfc/get-resolved-file-libraries cfg updated)
+ errors (not-empty (cfv/validate-file updated libs))
+ updated (if errors
+ (update updated :data cpc/process-changes
+ (cfr/repair-file updated libs errors))
+ updated)]
+
+ (db/insert! conn :file-change
+ {:id (uuid/next)
+ :session-id session-id
+ :profile-id profile-id
+ :created-at ts
+ :updated-at ts
+ :deleted-at (ct/in-future {:hours 1})
+ :file-id branch-file-id
+ :revn (:revn updated)
+ :version (:version updated)
+ :features (into-array (:features updated))
+ :changes (blob/encode (vec changes))}
+ {::db/return-keys false})
+
+ (fupd/persist-file! (assoc cfg ::fupd/timestamp ts) updated)
+ (reposition-base! ts (:revn updated))
+
+ (mbus/pub! msgbus
+ :topic branch-file-id
+ :message {:type :file-merged
+ :file-id branch-file-id
+ :session-id session-id
+ :revn (:revn updated)})
+
+ {:status :updated :revn (:revn updated)}))))))))))))
+
+;; --- COMMAND QUERY: get-file-branch-info
+
+(def ^:private schema:get-file-branch-info
+ [:map {:title "get-file-branch-info"}
+ [:file-id ::sm/uuid]])
+
+(def ^:private sql:get-file-branch-info
+ "SELECT fb.id,
+ fb.branch_file_id,
+ fb.source_file_id,
+ fb.name,
+ fb.description,
+ fb.status,
+ fb.created_by,
+ fb.created_at,
+ fb.updated_at,
+ fb.base_revn,
+ fb.base_branch_revn,
+ fb.base_snapshot_id,
+ bf.revn AS branch_revn,
+ sf.revn AS source_revn,
+ sf.name AS source_name
+ FROM file_branch AS fb
+ JOIN file AS bf ON (bf.id = fb.branch_file_id)
+ JOIN file AS sf ON (sf.id = fb.source_file_id)
+ WHERE fb.branch_file_id = ?
+ AND fb.deleted_at IS NULL")
+
+(sv/defmethod ::get-file-branch-info
+ "If the given file is a branch, return its branch metadata (with cheap
+ revn-based ahead/behind); otherwise nil. Returns nil when branching is
+ disabled, so it is safe to call on every file open."
+ {::doc/added "2.16"
+ ::sm/params schema:get-file-branch-info}
+ [cfg {:keys [::rpc/profile-id file-id]}]
+ (when (contains? cf/flags :branching)
+ (db/run! cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ (files/check-read-permissions! cfg profile-id file-id)
+ (when-let [{:keys [base-snapshot-id source-file-id status] :as row}
+ (db/exec-one! conn [sql:get-file-branch-info file-id])]
+ ;; `ahead`/`behind` count actual entity-level changes (what
+ ;; the compare dialog lists), NOT the raw revn delta — a
+ ;; single edit can span many save-revns and noise is filtered.
+ (let [[ahead-revn behind-revn] (revn-deltas row)
+ [ahead behind conflicts]
+ (if (= "open" status)
+ (branch-diff-counts cfg nil
+ {:id (:id row)
+ :source-file-id source-file-id
+ :branch-file-id file-id
+ :base-snapshot-id base-snapshot-id
+ :ahead-revn ahead-revn
+ :behind-revn behind-revn})
+ [0 0 0])]
+ (-> row
+ (assoc :ahead ahead :behind behind :conflicts conflicts)
+ (dissoc :branch-revn :source-revn :base-snapshot-id :base-branch-revn))))))))
+
+;; --- COMMAND: update-file-branch (rename / description)
+
+(def ^:private schema:update-file-branch
+ [:map {:title "update-file-branch"}
+ [:id ::sm/uuid]
+ [:name {:optional true} [:string {:max 250}]]
+ [:description {:optional true} [:string {:max 4000}]]])
+
+(sv/defmethod ::update-file-branch
+ "Rename a branch or update its description."
+ {::doc/added "2.16"
+ ::sm/params schema:update-file-branch
+ ::db/transaction true}
+ [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name description]}]
+ (check-branching-enabled!)
+ (when (and (some? name) (str/blank? name))
+ (ex/raise :type :validation
+ :code :invalid-branch-name
+ :hint "branch name cannot be blank"))
+ (let [branch (db/get* conn :file-branch {:id id})]
+ (when (or (nil? branch) (some? (:deleted-at branch)))
+ (ex/raise :type :not-found :code :branch-not-found :branch-id id))
+ (files/check-edition-permissions! cfg profile-id (:branch-file-id branch))
+ (db/update! conn :file-branch
+ (cond-> {:updated-at (ct/now)}
+ (some? name) (assoc :name name)
+ (some? description) (assoc :description description))
+ {:id id}
+ {::db/return-keys false})
+ {:id id
+ :name (or name (:name branch))
+ :description (or description (:description branch))}))
+
+;; --- COMMAND: archive-file-branch
+
+(def ^:private schema:archive-file-branch
+ [:map {:title "archive-file-branch"}
+ [:id ::sm/uuid]
+ [:archived {:optional true} ::sm/boolean]])
+
+(sv/defmethod ::archive-file-branch
+ "Archive (hide from the default list) or restore a branch."
+ {::doc/added "2.16"
+ ::sm/params schema:archive-file-branch
+ ::db/transaction true}
+ [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id archived]}]
+ (check-branching-enabled!)
+ (let [branch (db/get* conn :file-branch {:id id})]
+ (when (or (nil? branch) (some? (:deleted-at branch)))
+ (ex/raise :type :not-found :code :branch-not-found :branch-id id))
+ (when (= "merged" (:status branch))
+ (ex/raise :type :validation :code :branch-merged :branch-id id))
+ (files/check-edition-permissions! cfg profile-id (:branch-file-id branch))
+ (let [status (if (false? archived) "open" "archived")]
+ (db/update! conn :file-branch
+ {:status status :updated-at (ct/now)}
+ {:id id}
+ {::db/return-keys false})
+ ;; archiving hides the branch from the default flow, so its review
+ ;; sandbox has no reason to stay open either
+ (when (= "archived" status)
+ (let [team (teams/get-team conn :profile-id profile-id
+ :file-id (:branch-file-id branch))
+ dt (ct/in-future (ldel/get-deletion-delay team))]
+ (close-branch-pull-requests! cfg id {:profile-id profile-id
+ :status "closed"
+ :deleted-at dt})))
+ {:id id :status status})))
+
+;; --- COMMAND: delete-file-branch
+
+(def ^:private schema:delete-file-branch
+ [:map {:title "delete-file-branch"}
+ [:id ::sm/uuid]])
+
+(sv/defmethod ::delete-file-branch
+ "Logically delete a branch: marks the branch metadata and the branch
+ file as deleted (with the team's deletion delay) and schedules the
+ file object for GC, mirroring the normal file deletion path."
+ {::doc/added "2.16"
+ ::webhooks/event? true
+ ::sm/params schema:delete-file-branch}
+ [cfg {:keys [::rpc/profile-id ::rpc/session-id id]}]
+ (check-branching-enabled!)
+ (let [branch (db/get* cfg :file-branch {:id id})]
+ (when (or (nil? branch) (some? (:deleted-at branch)))
+ (ex/raise :type :not-found :code :branch-not-found :branch-id id))
+ (files/check-edition-permissions! cfg profile-id (:branch-file-id branch))
+ (db/tx-run!
+ cfg
+ (fn [cfg]
+ (delete-branch! cfg branch {:profile-id profile-id
+ :session-id session-id
+ :status "archived"})
+ {:status :deleted}))))
diff --git a/backend/src/app/rpc/commands/files_pull_request.clj b/backend/src/app/rpc/commands/files_pull_request.clj
new file mode 100644
index 0000000000..9b214a4491
--- /dev/null
+++ b/backend/src/app/rpc/commands/files_pull_request.clj
@@ -0,0 +1,887 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns app.rpc.commands.files-pull-request
+ "RPC commands for pull requests on top of file branching: a review
+ request over a pinned snapshot of a branch. A pull request does NOT
+ create a new file; the review sandbox is a virtual read-only view of
+ the branch served from the pinned snapshot (`get-pull-request-bundle`),
+ so it disappears when the pull request is closed and the snapshot pin
+ is released. Merging stays on `app.rpc.commands.files-branch`; approving
+ a pull request is informative and never a gate for the merge."
+ (:require
+ [app.binfile.common :as bfc]
+ [app.common.data :as d]
+ [app.common.exceptions :as ex]
+ [app.common.features :as-alias cfeat]
+ [app.common.schema :as sm]
+ [app.common.time :as ct]
+ [app.common.uri :as uri]
+ [app.common.uuid :as uuid]
+ [app.config :as cf]
+ [app.db :as db]
+ [app.email :as eml]
+ [app.features.file-snapshots :as fsnap]
+ [app.features.logical-deletion :as ldel]
+ [app.loggers.webhooks :as-alias webhooks]
+ [app.msgbus :as mbus]
+ [app.rpc :as-alias rpc]
+ [app.rpc.climit :as-alias climit]
+ [app.rpc.commands.files :as files]
+ [app.rpc.commands.files-branch :as fbranch]
+ [app.rpc.commands.profile :as profile]
+ [app.rpc.commands.teams :as teams]
+ [app.rpc.doc :as-alias doc]
+ [app.rpc.permissions :as perms]
+ [app.rpc.quotes :as quotes]
+ [app.util.services :as sv]
+ [clojure.string :as str]))
+
+(defn check-pull-requests-enabled!
+ "Guard the pull request commands behind the `:branching` product flag
+ (pull requests are part of the branching feature). Public: the viewer
+ bundle command guards its pr-id variant with it too."
+ []
+ (when-not (contains? cf/flags :branching)
+ (ex/raise :type :restriction
+ :code :branching-disabled
+ :hint "the branching feature is not enabled on this instance")))
+
+;; --- Helpers
+
+(defn- get-pull-request*
+ "Fetch a pull request row or raise :not-found."
+ [cfg id]
+ (let [pr (db/get* cfg :file-pull-request {:id id})]
+ (when (or (nil? pr) (some? (:deleted-at pr)))
+ (ex/raise :type :not-found
+ :code :pull-request-not-found
+ :hint "unable to find pull request with the provided id"
+ :pull-request-id id))
+ pr))
+
+(defn- check-author-or-admin!
+ "Managing a pull request (edit metadata, reviewers, close, reopen) is
+ allowed to its author and to team admins/owners. Raises the standard
+ not-found on failure so object existence is not leaked."
+ [cfg profile-id {:keys [created-by target-file-id]}]
+ (let [perms (perms/get-file-read-permissions cfg profile-id target-file-id)]
+ (files/check-read-permissions! perms)
+ (when-not (or (= created-by profile-id)
+ (:is-admin perms))
+ (ex/raise :type :not-found
+ :code :object-not-found
+ :hint "not found"))))
+
+(defn- release-review-snapshot!
+ "Reschedule a pull request's pinned review snapshot for normal deletion:
+ it is pinned ~10 years while the pull request is open and must be
+ released when the pull request is closed, merged or its snapshot is
+ repositioned. The snapshot belongs to the BRANCH file (the pull
+ request's source)."
+ [cfg {:keys [review-snapshot-id source-file-id]} deleted-at]
+ (when review-snapshot-id
+ (fsnap/delete! cfg
+ :id review-snapshot-id
+ :file-id source-file-id
+ :deleted-at deleted-at)))
+
+(defn- pin-review-snapshot!
+ "Materialize the review snapshot of the branch file: what reviewers see
+ in the sandbox, pinned so the snapshot GC does not prune it while the
+ pull request is open."
+ [cfg branch-file title profile-id]
+ (fsnap/create! cfg branch-file
+ {:label (str "pr-review/" title)
+ :profile-id profile-id
+ :created-by "system"
+ :deleted-at (ct/in-future {:days 3650})}))
+
+(defn- validate-reviewers!
+ "Check every reviewer id is a member of `team-id`; the author is
+ silently dropped (requesting a review from yourself is meaningless).
+ Returns the cleaned vector."
+ [cfg team-id author-id reviewers]
+ (let [reviewers (into [] (comp (distinct) (remove #(= author-id %))) reviewers)
+ members (into #{} (map :id) (teams/get-team-members cfg team-id))
+ invalid (remove members reviewers)]
+ (when (seq invalid)
+ (ex/raise :type :validation
+ :code :reviewer-not-team-member
+ :hint "all reviewers must be members of the team"
+ :profile-ids (vec invalid)))
+ reviewers))
+
+(def ^:private sql:get-reviews
+ "SELECT r.pull_request_id,
+ r.profile_id,
+ r.state,
+ r.reviewed_revn,
+ r.comment,
+ r.updated_at
+ FROM file_pull_request_review AS r
+ JOIN profile AS p ON (p.id = r.profile_id)
+ WHERE r.pull_request_id = ANY(?)
+ ORDER BY r.created_at ASC")
+
+(defn- get-reviews-map
+ "{pull-request-id -> [review ...]} for the given pull request ids, each
+ review annotated with `:stale` (verdict submitted over a previous
+ review snapshot)."
+ [cfg prs]
+ (if (empty? prs)
+ {}
+ (let [ids (db/create-array (db/get-connection cfg) "uuid" (mapv :id prs))
+ revn (into {} (map (juxt :id :review-revn)) prs)]
+ (->> (db/exec! cfg [sql:get-reviews ids])
+ (mapv (fn [{:keys [pull-request-id state reviewed-revn] :as row}]
+ (assoc row :stale (and (not= "pending" state)
+ (not= reviewed-revn (get revn pull-request-id))))))
+ (group-by :pull-request-id)))))
+
+(defn- review-state
+ "Aggregate verdict of a pull request: `changes-requested` beats
+ `approved`; stale verdicts (submitted over a previous snapshot) only
+ count as `in-review`; no verdict at all is `pending`."
+ [reviews]
+ (let [submitted (remove #(= "pending" (:state %)) reviews)
+ current (remove :stale submitted)]
+ (cond
+ (some #(= "changes-requested" (:state %)) current) "changes-requested"
+ (some #(= "approved" (:state %)) current) "approved"
+ (seq submitted) "in-review"
+ :else "pending")))
+
+(defn- decorate-pull-request
+ "Attach the derived attributes the UI needs: reviews (+ aggregate
+ `review-state`), `outdated` (the branch moved past the review snapshot,
+ cheap revn gate) and entity-level ahead/behind/conflicts counts against
+ main (only for open pull requests, gated like the branches listing)."
+ [cfg main-data reviews-map {:keys [id status source-file-id target-file-id
+ base-snapshot-id review-revn branch-revn]
+ :as row}]
+ (let [reviews (get reviews-map id [])
+ open? (= "open" status)
+ [ahead-revn behind-revn] (fbranch/revn-deltas row)
+ [ahead behind conflicts]
+ (if open?
+ (fbranch/branch-diff-counts cfg main-data
+ {:id id
+ :source-file-id target-file-id
+ :branch-file-id source-file-id
+ :base-snapshot-id base-snapshot-id
+ :ahead-revn ahead-revn
+ :behind-revn behind-revn})
+ [0 0 0])]
+ (-> row
+ (assoc :reviews (mapv #(dissoc % :pull-request-id) reviews))
+ (assoc :review-state (review-state reviews))
+ (assoc :outdated (and open? (> branch-revn review-revn)))
+ (assoc :ahead ahead :behind behind :conflicts conflicts)
+ (dissoc :branch-revn :source-revn :base-snapshot-id
+ :base-revn :base-branch-revn :review-snapshot-id))))
+
+(def ^:private sql:get-open-pull-request-for-branch
+ "SELECT id FROM file_pull_request
+ WHERE file_branch_id = ?
+ AND status = 'open'
+ AND deleted_at IS NULL")
+
+;; --- Helpers: reviewer notification emails
+
+(defn- format-pull-request-url
+ "Deep link into the review sandbox: the branch file's workspace with the
+ pr-id param."
+ [{:keys [id source-file-id]} team-id]
+ (str/join [(cf/get :public-uri)
+ "/#/workspace?"
+ (uri/map->query-string {:file-id source-file-id
+ :team-id team-id
+ :pr-id id})]))
+
+(defn- send-review-request-emails!
+ "Notify `reviewers` (profile ids) that `actor-id` requested their review
+ on the pull request. Follows the same in-transaction outbox pattern as
+ the comment emails."
+ [conn {:keys [title description] :as pr} {:keys [team-id actor-id branch-name target-name reviewers]}]
+ (when (seq reviewers)
+ (let [users (->> (teams/get-users+props conn team-id)
+ (map profile/decode-row)
+ (d/index-by :id))
+ actor (get users actor-id)
+ route (str/join [branch-name " → " target-name])
+ url (format-pull-request-url pr team-id)]
+ (doseq [reviewer-id reviewers]
+ (when-let [{:keys [fullname email]} (get users reviewer-id)]
+ (eml/send!
+ {::eml/conn conn
+ ::eml/factory eml/review-request
+ :public-uri (cf/get :public-uri)
+ :to email
+ :name fullname
+ :source-user (get actor :fullname "")
+ :pull-request-title title
+ :pull-request-route route
+ :pull-request-description (or description "")
+ :pull-request-url url}))))))
+
+;; --- COMMAND: create-pull-request
+
+(def ^:private schema:create-pull-request
+ [:map {:title "create-pull-request"}
+ [:branch-id ::sm/uuid]
+ [:title [:string {:max 250}]]
+ [:description {:optional true} [:string {:max 4000}]]
+ [:reviewers {:optional true} [:vector ::sm/uuid]]])
+
+(sv/defmethod ::create-pull-request
+ "Create a pull request from an open branch towards its source file
+ (main). Pins a review snapshot of the branch (the state reviewers see)
+ and registers the requested reviewers, who must be members of the
+ team. At most one open pull request may exist per branch."
+ {::doc/added "2.16"
+ ::webhooks/event? true
+ ::sm/params schema:create-pull-request
+ ::climit/id [[:create-pull-request/by-profile ::rpc/profile-id]
+ [:create-pull-request/global]]}
+ [{:keys [::mbus/msgbus] :as cfg}
+ {:keys [::rpc/profile-id branch-id title description reviewers]}]
+ (check-pull-requests-enabled!)
+ (when (str/blank? title)
+ (ex/raise :type :validation
+ :code :invalid-pull-request-title
+ :hint "pull request title cannot be blank"))
+ (let [branch (db/get* cfg :file-branch {:id branch-id})]
+ (when (or (nil? branch) (some? (:deleted-at branch)))
+ (ex/raise :type :not-found
+ :code :branch-not-found
+ :branch-id branch-id))
+ (when (not= "open" (:status branch))
+ (ex/raise :type :validation
+ :code :branch-not-open
+ :branch-id branch-id))
+
+ (let [branch-file-id (:branch-file-id branch)
+ target-id (:source-file-id branch)]
+
+ ;; Requesting a review is an author-side action: it needs edition
+ ;; permissions on the BRANCH file (merging keeps requiring edition
+ ;; permissions on main, unchanged). Checked BEFORE validating the
+ ;; reviewers so team membership cannot be probed without access.
+ (files/check-edition-permissions! cfg profile-id branch-file-id)
+
+ (let [target-row (db/get-by-id cfg :file target-id)
+ project (db/get-by-id cfg :project (:project-id target-row))
+ team-id (:team-id project)
+ reviewers (validate-reviewers! cfg team-id profile-id (or reviewers []))]
+
+ (-> cfg
+ (assoc ::quotes/profile-id profile-id)
+ (assoc ::quotes/team-id team-id)
+ (quotes/check! {::quotes/id ::quotes/pull-requests-per-team}))
+
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ ;; Serialize against concurrent update-file on the branch so
+ ;; the review snapshot captures a consistent state (same
+ ;; advisory lock the save path takes), and against a
+ ;; concurrent create-pull-request on the same branch.
+ (db/xact-lock! conn branch-file-id)
+
+ (when (db/exec-one! conn [sql:get-open-pull-request-for-branch branch-id])
+ (ex/raise :type :validation
+ :code :pull-request-already-exists
+ :hint "the branch already has an open pull request"
+ :branch-id branch-id))
+
+ (let [branch-file (bfc/get-file cfg branch-file-id :realize? true)
+ snapshot (pin-review-snapshot! cfg branch-file title profile-id)
+ pr-id (uuid/next)
+ ts (ct/now)]
+
+ (db/insert! conn :file-pull-request
+ {:id pr-id
+ :file-branch-id branch-id
+ :source-file-id branch-file-id
+ :target-file-id target-id
+ :title title
+ :description description
+ :created-by profile-id
+ :status "open"
+ :review-snapshot-id (:id snapshot)
+ :review-revn (:revn branch-file)
+ :review-updated-at ts}
+ {::db/return-keys false})
+
+ (doseq [reviewer-id reviewers]
+ (db/insert! conn :file-pull-request-review
+ {:pull-request-id pr-id
+ :profile-id reviewer-id}
+ {::db/return-keys false}))
+
+ (send-review-request-emails! conn
+ {:id pr-id
+ :source-file-id branch-file-id
+ :title title
+ :description description}
+ {:team-id team-id
+ :actor-id profile-id
+ :branch-name (:name branch)
+ :target-name (:name target-row)
+ :reviewers reviewers})
+
+ (mbus/pub! msgbus
+ :topic target-id
+ :message {:type :pull-request-created
+ :file-id target-id
+ :pull-request-id pr-id
+ :profile-id profile-id})
+
+ {:id pr-id
+ :file-branch-id branch-id
+ :source-file-id branch-file-id
+ :target-file-id target-id
+ :title title
+ :description description
+ :status "open"
+ :review-revn (:revn branch-file)
+ :reviewers reviewers})))))))
+
+;; --- COMMAND QUERY: get-file-pull-requests
+
+(def ^:private schema:get-file-pull-requests
+ [:map {:title "get-file-pull-requests"}
+ [:file-id ::sm/uuid]
+ [:include-closed {:optional true} ::sm/boolean]])
+
+(def ^:private sql:resolve-target-file
+ "SELECT source_file_id FROM file_branch
+ WHERE branch_file_id = ?
+ AND deleted_at IS NULL")
+
+(def ^:private sql:get-file-pull-requests
+ "SELECT fpr.id,
+ fpr.file_branch_id,
+ fpr.source_file_id,
+ fpr.target_file_id,
+ fpr.title,
+ fpr.description,
+ fpr.created_by,
+ fpr.created_at,
+ fpr.updated_at,
+ fpr.status,
+ fpr.review_snapshot_id,
+ fpr.review_revn,
+ fpr.review_updated_at,
+ fpr.closed_at,
+ fpr.closed_by,
+ fb.name AS branch_name,
+ fb.base_snapshot_id,
+ fb.base_revn,
+ fb.base_branch_revn,
+ bf.revn AS branch_revn,
+ sf.revn AS source_revn,
+ sf.name AS target_name
+ FROM file_pull_request AS fpr
+ JOIN file_branch AS fb ON (fb.id = fpr.file_branch_id)
+ JOIN file AS bf ON (bf.id = fpr.source_file_id)
+ JOIN file AS sf ON (sf.id = fpr.target_file_id)
+ WHERE fpr.target_file_id = ?
+ AND fpr.deleted_at IS NULL
+ AND (?::boolean OR fpr.status = 'open')
+ ORDER BY fpr.created_at DESC")
+
+(sv/defmethod ::get-file-pull-requests
+ "List the pull requests of a file. Accepts either the target file
+ (main) or a branch file id (it resolves to its source). Every open
+ pull request is annotated with its reviews, the aggregate
+ `review-state`, the `outdated` flag and the entity-level
+ ahead/behind/conflicts counts (gated by the cheap revn deltas, with
+ main realized once and shared, like the branches listing)."
+ {::doc/added "2.16"
+ ::sm/params schema:get-file-pull-requests}
+ [cfg {:keys [::rpc/profile-id file-id include-closed]}]
+ (check-pull-requests-enabled!)
+ (db/run! cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ (let [target-id (or (:source-file-id (db/exec-one! conn [sql:resolve-target-file file-id]))
+ file-id)]
+ (files/check-read-permissions! cfg profile-id target-id)
+ (let [rows (db/exec! conn [sql:get-file-pull-requests target-id
+ (boolean include-closed)])
+ reviews-map (get-reviews-map cfg rows)
+ open? (fn [row] (= "open" (:status row)))
+ need? (some (fn [r] (and (open? r)
+ (let [[a b] (fbranch/revn-deltas r)]
+ (or (pos? a) (pos? b)))))
+ rows)
+ main-data (when need? (:data (bfc/get-file cfg target-id :realize? true)))]
+ (mapv (partial decorate-pull-request cfg main-data reviews-map) rows))))))
+
+;; --- COMMAND QUERY: get-pull-request
+
+(def ^:private schema:get-pull-request
+ [:map {:title "get-pull-request"}
+ [:id ::sm/uuid]])
+
+(def ^:private sql:get-pull-request
+ "SELECT fpr.id,
+ fpr.file_branch_id,
+ fpr.source_file_id,
+ fpr.target_file_id,
+ fpr.title,
+ fpr.description,
+ fpr.created_by,
+ fpr.created_at,
+ fpr.updated_at,
+ fpr.status,
+ fpr.review_snapshot_id,
+ fpr.review_revn,
+ fpr.review_updated_at,
+ fpr.closed_at,
+ fpr.closed_by,
+ fb.name AS branch_name,
+ fb.base_snapshot_id,
+ fb.base_revn,
+ fb.base_branch_revn,
+ bf.revn AS branch_revn,
+ sf.revn AS source_revn,
+ sf.name AS target_name
+ FROM file_pull_request AS fpr
+ JOIN file_branch AS fb ON (fb.id = fpr.file_branch_id)
+ JOIN file AS bf ON (bf.id = fpr.source_file_id)
+ JOIN file AS sf ON (sf.id = fpr.target_file_id)
+ WHERE fpr.id = ?
+ AND fpr.deleted_at IS NULL")
+
+(sv/defmethod ::get-pull-request
+ "Fetch a single pull request with its reviews, aggregate review state
+ and diff counts."
+ {::doc/added "2.16"
+ ::sm/params schema:get-pull-request}
+ [cfg {:keys [::rpc/profile-id id]}]
+ (check-pull-requests-enabled!)
+ (db/run! cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ (let [row (db/exec-one! conn [sql:get-pull-request id])]
+ (when-not row
+ (ex/raise :type :not-found
+ :code :pull-request-not-found
+ :pull-request-id id))
+ (files/check-read-permissions! cfg profile-id (:target-file-id row))
+ (decorate-pull-request cfg nil (get-reviews-map cfg [row]) row)))))
+
+;; --- COMMAND QUERY: get-pull-request-bundle
+
+(def ^:private schema:get-pull-request-bundle
+ [:map {:title "get-pull-request-bundle"}
+ [:id ::sm/uuid]
+ [:features {:optional true} ::cfeat/features]])
+
+(sv/defmethod ::get-pull-request-bundle
+ "Retrieve the review sandbox of an OPEN pull request: the branch file
+ bundle with the data of the pinned review snapshot overlaid, served
+ with edition permissions stripped so the client always opens it
+ read-only. Does not modify any database state. Refuses when the
+ snapshot cannot be resolved — silently serving the live branch would
+ show reviewers a state nobody asked them to review."
+ {::doc/added "2.16"
+ ::sm/params schema:get-pull-request-bundle
+ ::sm/result files/schema:file-with-permissions
+ ::db/transaction true}
+ [cfg {:keys [::rpc/profile-id id]}]
+ (check-pull-requests-enabled!)
+ (let [pr (get-pull-request* cfg id)]
+ (when (not= "open" (:status pr))
+ (ex/raise :type :validation
+ :code :pull-request-not-open
+ :hint "the review sandbox only exists while the pull request is open"
+ :pull-request-id id))
+ (let [perms (perms/get-file-read-permissions cfg profile-id (:target-file-id pr))]
+ (files/check-read-permissions! perms)
+ (let [snapshot (when (:review-snapshot-id pr)
+ (fsnap/get-snapshot cfg (:source-file-id pr) (:review-snapshot-id pr)))]
+ (when-not snapshot
+ (ex/raise :type :not-found
+ :code :review-snapshot-missing
+ :hint "the pull request review snapshot cannot be resolved"
+ :pull-request-id id))
+ (let [base-file (bfc/get-file cfg (:source-file-id pr) :load-data? false)]
+ (-> base-file
+ (assoc :data (:data snapshot))
+ (assoc :version (:version snapshot))
+ (assoc :features (:features snapshot))
+ (assoc :revn (:revn snapshot))
+ (assoc :vern (rand-int 100000))
+ ;; the sandbox is read-only by construction, whatever the
+ ;; caller's role on the team is
+ (assoc :permissions (assoc perms
+ :can-edit false
+ :is-admin false
+ :is-owner false))))))))
+
+;; --- COMMAND: update-pull-request
+
+(def ^:private schema:update-pull-request
+ [:map {:title "update-pull-request"}
+ [:id ::sm/uuid]
+ [:title {:optional true} [:string {:max 250}]]
+ [:description {:optional true} [:string {:max 4000}]]
+ ;; full replacement of the reviewer set; added reviewers start as
+ ;; pending, removed ones lose their row (their comments remain)
+ [:reviewers {:optional true} [:vector ::sm/uuid]]])
+
+(sv/defmethod ::update-pull-request
+ "Edit a pull request's title, description or reviewer list. Allowed to
+ the author and to team admins/owners."
+ {::doc/added "2.16"
+ ::sm/params schema:update-pull-request}
+ [{:keys [::mbus/msgbus] :as cfg}
+ {:keys [::rpc/profile-id id title description reviewers]}]
+ (check-pull-requests-enabled!)
+ (when (and (some? title) (str/blank? title))
+ (ex/raise :type :validation
+ :code :invalid-pull-request-title
+ :hint "pull request title cannot be blank"))
+ (let [pr (get-pull-request* cfg id)]
+ (when (not= "open" (:status pr))
+ (ex/raise :type :validation
+ :code :pull-request-not-open
+ :pull-request-id id))
+ (check-author-or-admin! cfg profile-id pr)
+
+ (let [team-id (perms/get-team-id-for-file cfg (:target-file-id pr))
+ reviewers (some->> reviewers
+ (validate-reviewers! cfg team-id (:created-by pr)))]
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn]}]
+ (db/update! conn :file-pull-request
+ (cond-> {:updated-at (ct/now)}
+ (some? title) (assoc :title title)
+ (some? description) (assoc :description description))
+ {:id id}
+ {::db/return-keys false})
+
+ (when (some? reviewers)
+ (let [current (->> (db/query conn :file-pull-request-review
+ {:pull-request-id id})
+ (into #{} (map :profile-id)))
+ wanted (set reviewers)
+ added (vec (remove current wanted))]
+ (doseq [reviewer-id added]
+ (db/insert! conn :file-pull-request-review
+ {:pull-request-id id
+ :profile-id reviewer-id}
+ {::db/return-keys false}))
+ (doseq [reviewer-id (remove wanted current)]
+ (db/delete! conn :file-pull-request-review
+ {:pull-request-id id
+ :profile-id reviewer-id}))
+ (when (seq added)
+ (let [row (db/exec-one! conn [sql:get-pull-request id])]
+ (send-review-request-emails! conn row
+ {:team-id team-id
+ :actor-id profile-id
+ :branch-name (:branch-name row)
+ :target-name (:target-name row)
+ :reviewers added})))))
+
+ (mbus/pub! msgbus
+ :topic (:target-file-id pr)
+ :message {:type :pull-request-updated
+ :file-id (:target-file-id pr)
+ :pull-request-id id
+ :profile-id profile-id})
+
+ {:id id
+ :title (or title (:title pr))
+ :description (or description (:description pr))})))))
+
+;; --- COMMAND: update-pull-request-snapshot
+
+(def ^:private schema:update-pull-request-snapshot
+ [:map {:title "update-pull-request-snapshot"}
+ [:id ::sm/uuid]])
+
+(sv/defmethod ::update-pull-request-snapshot
+ "Publish the branch's current state to the reviewers: reposition the
+ review snapshot to the head of the branch (releasing the previous
+ pin). Verdicts submitted over the previous snapshot become stale
+ (`reviewed-revn` no longer matches) but are kept for the history. It
+ is an explicit author action — the sandbox never moves on its own."
+ {::doc/added "2.16"
+ ::webhooks/event? true
+ ::sm/params schema:update-pull-request-snapshot
+ ::climit/id [[:update-pull-request-snapshot/global]]}
+ [{:keys [::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id id]}]
+ (check-pull-requests-enabled!)
+ (let [pr (get-pull-request* cfg id)]
+ (when (not= "open" (:status pr))
+ (ex/raise :type :validation
+ :code :pull-request-not-open
+ :pull-request-id id))
+
+ ;; publishing the branch state is an author-side action, like
+ ;; creating the pull request
+ (files/check-edition-permissions! cfg profile-id (:source-file-id pr))
+
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ ;; serialize against concurrent update-file on the branch so the
+ ;; new snapshot captures a consistent state
+ (db/xact-lock! conn (:source-file-id pr))
+
+ (let [branch-file (bfc/get-file cfg (:source-file-id pr) :realize? true)]
+ (if (= (:revn branch-file) (:review-revn pr))
+ ;; nothing new to publish: the sandbox already shows this state
+ {:id id :review-revn (:review-revn pr) :updated false}
+
+ (let [team (teams/get-team conn :profile-id profile-id
+ :file-id (:target-file-id pr))
+ delay (ldel/get-deletion-delay team)
+ snapshot (pin-review-snapshot! cfg branch-file (:title pr) profile-id)
+ ts (ct/now)]
+ ;; the previous review snapshot is superseded: release its pin
+ (release-review-snapshot! cfg pr (ct/in-future delay))
+ (db/update! conn :file-pull-request
+ {:review-snapshot-id (:id snapshot)
+ :review-revn (:revn branch-file)
+ :review-updated-at ts
+ :updated-at ts}
+ {:id id}
+ {::db/return-keys false})
+
+ (mbus/pub! msgbus
+ :topic (:target-file-id pr)
+ :message {:type :pull-request-updated
+ :file-id (:target-file-id pr)
+ :pull-request-id id
+ :profile-id profile-id})
+
+ {:id id :review-revn (:revn branch-file) :updated true})))))))
+
+;; --- COMMAND: submit-pull-request-review
+
+(def ^:private schema:submit-pull-request-review
+ [:map {:title "submit-pull-request-review"}
+ [:id ::sm/uuid]
+ [:state [:enum "approved" "changes-requested" "pending"]]
+ [:comment {:optional true} [:string {:max 4000}]]])
+
+(sv/defmethod ::submit-pull-request-review
+ "Submit (or retract, with `pending`) a verdict on an open pull
+ request. Only assigned reviewers may submit; the verdict records the
+ review revision it was issued over, so it goes stale when the author
+ publishes newer changes. Approval is informative: it never gates the
+ merge, which keeps requiring edition permissions on main."
+ {::doc/added "2.16"
+ ::webhooks/event? true
+ ::sm/params schema:submit-pull-request-review}
+ [{:keys [::mbus/msgbus] :as cfg}
+ {:keys [::rpc/profile-id id state comment]}]
+ (check-pull-requests-enabled!)
+ (let [pr (get-pull-request* cfg id)]
+ (when (not= "open" (:status pr))
+ (ex/raise :type :validation
+ :code :pull-request-not-open
+ :pull-request-id id))
+ (files/check-read-permissions! cfg profile-id (:target-file-id pr))
+
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn]}]
+ (let [review (db/get* conn :file-pull-request-review
+ {:pull-request-id id :profile-id profile-id})]
+ (when-not review
+ (ex/raise :type :validation
+ :code :not-a-reviewer
+ :hint "only assigned reviewers can submit a review"
+ :pull-request-id id))
+
+ (db/update! conn :file-pull-request-review
+ {:state state
+ :reviewed-revn (when (not= "pending" state) (:review-revn pr))
+ :comment comment
+ :updated-at (ct/now)}
+ {:pull-request-id id :profile-id profile-id}
+ {::db/return-keys false})
+
+ (mbus/pub! msgbus
+ :topic (:target-file-id pr)
+ :message {:type :pull-request-review-submitted
+ :file-id (:target-file-id pr)
+ :pull-request-id id
+ :profile-id profile-id
+ :state state})
+
+ {:id id
+ :profile-id profile-id
+ :state state
+ :reviewed-revn (:review-revn pr)})))))
+
+;; --- COMMAND: close-pull-request
+
+(def ^:private schema:close-pull-request
+ [:map {:title "close-pull-request"}
+ [:id ::sm/uuid]])
+
+(sv/defmethod ::close-pull-request
+ "Close (discard) an open pull request: the review sandbox stops
+ resolving and the pinned review snapshot is released to the normal
+ snapshot GC. The branch itself is not touched. Allowed to the author
+ and to team admins/owners."
+ {::doc/added "2.16"
+ ::webhooks/event? true
+ ::sm/params schema:close-pull-request}
+ [{:keys [::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id id]}]
+ (check-pull-requests-enabled!)
+ (let [pr (get-pull-request* cfg id)]
+ (when (not= "open" (:status pr))
+ (ex/raise :type :validation
+ :code :pull-request-not-open
+ :pull-request-id id))
+ (check-author-or-admin! cfg profile-id pr)
+
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ (let [team (teams/get-team conn :profile-id profile-id
+ :file-id (:target-file-id pr))
+ delay (ldel/get-deletion-delay team)
+ ts (ct/now)]
+ (db/update! conn :file-pull-request
+ {:status "closed"
+ :closed-at ts
+ :closed-by profile-id
+ :updated-at ts}
+ {:id id}
+ {::db/return-keys false})
+ (release-review-snapshot! cfg pr (ct/in-future delay))
+
+ (mbus/pub! msgbus
+ :topic (:target-file-id pr)
+ :message {:type :pull-request-closed
+ :file-id (:target-file-id pr)
+ :pull-request-id id
+ :profile-id profile-id})
+
+ {:id id :status "closed"})))))
+
+;; --- COMMAND: reopen-pull-request
+
+(def ^:private schema:reopen-pull-request
+ [:map {:title "reopen-pull-request"}
+ [:id ::sm/uuid]])
+
+(sv/defmethod ::reopen-pull-request
+ "Reopen a closed pull request, as long as its branch is still open and
+ the branch has no other open pull request. The review snapshot is
+ re-pinned at the CURRENT head of the branch (the previous one may
+ already be garbage-collected). Allowed to the author and to team
+ admins/owners."
+ {::doc/added "2.16"
+ ::sm/params schema:reopen-pull-request}
+ [{:keys [::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id id]}]
+ (check-pull-requests-enabled!)
+ (let [pr (get-pull-request* cfg id)]
+ (when (not= "closed" (:status pr))
+ (ex/raise :type :validation
+ :code :pull-request-not-closed
+ :hint "only closed pull requests can be reopened"
+ :pull-request-id id))
+ (check-author-or-admin! cfg profile-id pr)
+
+ (let [branch (db/get* cfg :file-branch {:id (:file-branch-id pr)})]
+ (when (or (nil? branch)
+ (some? (:deleted-at branch))
+ (not= "open" (:status branch)))
+ (ex/raise :type :validation
+ :code :branch-not-open
+ :hint "the pull request's branch is no longer open"
+ :pull-request-id id))
+
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ (db/xact-lock! conn (:source-file-id pr))
+
+ (when (db/exec-one! conn [sql:get-open-pull-request-for-branch (:file-branch-id pr)])
+ (ex/raise :type :validation
+ :code :pull-request-already-exists
+ :hint "the branch already has an open pull request"
+ :branch-id (:file-branch-id pr)))
+
+ (let [branch-file (bfc/get-file cfg (:source-file-id pr) :realize? true)
+ snapshot (pin-review-snapshot! cfg branch-file (:title pr) profile-id)
+ ts (ct/now)]
+ (db/update! conn :file-pull-request
+ {:status "open"
+ :closed-at nil
+ :closed-by nil
+ :review-snapshot-id (:id snapshot)
+ :review-revn (:revn branch-file)
+ :review-updated-at ts
+ :updated-at ts}
+ {:id id}
+ {::db/return-keys false})
+
+ (mbus/pub! msgbus
+ :topic (:target-file-id pr)
+ :message {:type :pull-request-updated
+ :file-id (:target-file-id pr)
+ :pull-request-id id
+ :profile-id profile-id})
+
+ {:id id :status "open" :review-revn (:revn branch-file)}))))))
+
+;; --- COMMAND QUERY: get-profile-pending-reviews
+
+(def ^:private schema:get-profile-pending-reviews
+ [:map {:title "get-profile-pending-reviews"}
+ [:team-id ::sm/uuid]])
+
+(def ^:private sql:get-profile-pending-reviews
+ "SELECT fpr.id,
+ fpr.title,
+ fpr.created_by,
+ fpr.created_at,
+ fpr.review_updated_at,
+ fpr.source_file_id,
+ fpr.target_file_id,
+ fb.name AS branch_name,
+ sf.name AS target_name
+ FROM file_pull_request_review AS r
+ JOIN file_pull_request AS fpr ON (fpr.id = r.pull_request_id)
+ JOIN file_branch AS fb ON (fb.id = fpr.file_branch_id)
+ JOIN file AS sf ON (sf.id = fpr.target_file_id)
+ JOIN project AS p ON (p.id = sf.project_id)
+ WHERE r.profile_id = ?
+ AND p.team_id = ?
+ AND fpr.status = 'open'
+ AND fpr.deleted_at IS NULL
+ AND sf.deleted_at IS NULL
+ AND p.deleted_at IS NULL
+ AND (r.state = 'pending'
+ OR r.reviewed_revn IS DISTINCT FROM fpr.review_revn)
+ ORDER BY fpr.review_updated_at DESC")
+
+(sv/defmethod ::get-profile-pending-reviews
+ "The open pull requests of a team where the calling profile is a
+ reviewer without a CURRENT verdict (never reviewed, or the author
+ published newer changes since their last verdict). Feeds the dashboard
+ notifications area."
+ {::doc/added "2.16"
+ ::sm/params schema:get-profile-pending-reviews}
+ [cfg {:keys [::rpc/profile-id team-id]}]
+ (if-not (contains? cf/flags :branching)
+ []
+ (db/run! cfg
+ (fn [{:keys [::db/conn]}]
+ (teams/check-read-permissions! conn profile-id team-id)
+ (db/exec! conn [sql:get-profile-pending-reviews profile-id team-id])))))
diff --git a/backend/src/app/rpc/commands/search.clj b/backend/src/app/rpc/commands/search.clj
index 1186b15554..7ba2c692dc 100644
--- a/backend/src/app/rpc/commands/search.clj
+++ b/backend/src/app/rpc/commands/search.clj
@@ -50,6 +50,7 @@
inner join projects as pr on (f.project_id = pr.id)
where f.name ilike ('%' || ? || '%')
and (f.deleted_at is null)
+ and (f.is_branch is false)
order by f.created_at asc")
(defn search-files
diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj
index d36915d640..0d1421aa0d 100644
--- a/backend/src/app/rpc/commands/viewer.clj
+++ b/backend/src/app/rpc/commands/viewer.clj
@@ -15,7 +15,9 @@
[app.config :as cf]
[app.db :as db]
[app.features.fdata :as fdata]
+ [app.features.file-snapshots :as fsnap]
[app.rpc :as-alias rpc]
+ [app.rpc.commands.files-pull-request :as fpr]
[app.rpc.commands.teams :as teams]
[app.rpc.cond :as-alias cond]
[app.rpc.doc :as-alias doc]
@@ -131,6 +133,16 @@
[{:keys [::db/conn] :as cfg} {:keys [profile-id file-id share-id ::perms] :as params}]
(let [file (bfc/get-file cfg file-id)
+ ;; pull request review sandbox: the viewer shows the pinned
+ ;; review snapshot of the branch, not its live state
+ file (if-let [snapshot (::snapshot params)]
+ (-> file
+ (assoc :data (:data snapshot))
+ (assoc :version (:version snapshot))
+ (assoc :features (:features snapshot))
+ (assoc :revn (:revn snapshot)))
+ file)
+
project (db/get conn :project
{:id (:project-id file)}
{:columns [:id :name :team-id]})
@@ -210,23 +222,62 @@
:team (assoc team :permissions perms)
:permissions perms}))
+(defn- get-pull-request-view-context
+ "Resolve the review-sandbox context of `pr-id`: permissions taken from
+ the pull request's TARGET file (share-links do not apply here) with
+ edition stripped, plus the pinned review snapshot whose data replaces
+ the branch file's live state. Refuses when the pull request is not
+ open or does not belong to `file-id` — the sandbox must never silently
+ degrade to the live branch."
+ [system profile-id file-id pr-id]
+ (fpr/check-pull-requests-enabled!)
+ (let [pr (db/get* system :file-pull-request {:id pr-id})]
+ (when (or (nil? pr)
+ (some? (:deleted-at pr))
+ (not= file-id (:source-file-id pr)))
+ (ex/raise :type :not-found
+ :code :pull-request-not-found
+ :hint "unable to find pull request with the provided id"
+ :pull-request-id pr-id))
+ (when (not= "open" (:status pr))
+ (ex/raise :type :validation
+ :code :pull-request-not-open
+ :hint "the review sandbox only exists while the pull request is open"
+ :pull-request-id pr-id))
+ (let [perms (perms/get-file-read-permissions system profile-id (:target-file-id pr))
+ snapshot (when (:review-snapshot-id pr)
+ (fsnap/get-snapshot system (:source-file-id pr) (:review-snapshot-id pr)))]
+ (when (and perms (nil? snapshot))
+ (ex/raise :type :not-found
+ :code :review-snapshot-missing
+ :hint "the pull request review snapshot cannot be resolved"
+ :pull-request-id pr-id))
+ {:perms (some-> perms (assoc :can-edit false :is-admin false :is-owner false))
+ :snapshot snapshot})))
+
(def schema:get-view-only-bundle
[:map {:title "get-view-only-bundle"}
[:file-id ::sm/uuid]
[:share-id {:optional true} ::sm/uuid]
+ [:pr-id {:optional true} ::sm/uuid]
[:features {:optional true} ::cfeat/features]])
(sv/defmethod ::get-view-only-bundle
{::rpc/auth false
::doc/added "1.17"
::sm/params schema:get-view-only-bundle}
- [system {:keys [::rpc/profile-id file-id share-id] :as params}]
+ [system {:keys [::rpc/profile-id file-id share-id pr-id] :as params}]
(db/run! system
(fn [system]
- (let [perms (perms/get-file-read-permissions system profile-id file-id share-id)
+ (let [{:keys [perms snapshot]}
+ (if (some? pr-id)
+ (get-pull-request-view-context system profile-id file-id pr-id)
+ {:perms (perms/get-file-read-permissions system profile-id file-id share-id)})
+
params (-> params
(assoc ::perms perms)
- (assoc :profile-id profile-id))]
+ (assoc :profile-id profile-id)
+ (cond-> (some? snapshot) (assoc ::snapshot snapshot)))]
;; When we have neither profile nor share, we just return a not
;; found response to the user.
diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj
index 2cbe336a8d..3afb7a1354 100644
--- a/backend/src/app/rpc/quotes.clj
+++ b/backend/src/app/rpc/quotes.clj
@@ -471,6 +471,104 @@
(assoc ::count-sql [sql:get-snapshots-per-team team-id])
(generic-check!)))
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: BRANCHES-PER-FILE
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private schema:branches-per-file
+ [:map
+ [::profile-id ::sm/uuid]
+ [::project-id ::sm/uuid]
+ [::team-id ::sm/uuid]
+ [::file-id ::sm/uuid]])
+
+(def ^:private valid-branches-per-file-quote?
+ (sm/lazy-validator schema:branches-per-file))
+
+(def ^:private sql:get-branches-per-file
+ "SELECT count(*) AS total
+ FROM file_branch AS fb
+ WHERE fb.source_file_id = ?
+ AND fb.deleted_at IS NULL
+ AND fb.status <> 'merged'")
+
+(defmethod check-quote ::branches-per-file
+ [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
+ (assert (valid-branches-per-file-quote? quote) "invalid quote parameters")
+ (-> quote
+ (assoc ::default (cf/get :quotes-branches-per-file Integer/MAX_VALUE))
+ (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
+ profile-id team-id profile-id profile-id])
+ (assoc ::count-sql [sql:get-branches-per-file file-id])
+ (generic-check!)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: BRANCHES-PER-TEAM
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private schema:branches-per-team
+ [:map
+ [::profile-id ::sm/uuid]
+ [::team-id ::sm/uuid]])
+
+(def ^:private valid-branches-per-team-quote?
+ (sm/lazy-validator schema:branches-per-team))
+
+(def ^:private sql:get-branches-per-team
+ "SELECT count(*) AS total
+ FROM file_branch AS fb
+ JOIN file AS f ON (f.id = fb.source_file_id)
+ JOIN project AS p ON (p.id = f.project_id)
+ WHERE p.team_id = ?
+ AND fb.deleted_at IS NULL
+ AND fb.status <> 'merged'
+ AND f.deleted_at IS NULL
+ AND p.deleted_at IS NULL")
+
+(defmethod check-quote ::branches-per-team
+ [{:keys [::profile-id ::team-id ::target] :as quote}]
+ (assert (valid-branches-per-team-quote? quote) "invalid quote parameters")
+ (-> quote
+ (assoc ::default (cf/get :quotes-branches-per-team Integer/MAX_VALUE))
+ (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
+ (assoc ::count-sql [sql:get-branches-per-team team-id])
+ (generic-check!)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: PULL-REQUESTS-PER-TEAM
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private schema:pull-requests-per-team
+ [:map
+ [::profile-id ::sm/uuid]
+ [::team-id ::sm/uuid]])
+
+(def ^:private valid-pull-requests-per-team-quote?
+ (sm/lazy-validator schema:pull-requests-per-team))
+
+(def ^:private sql:get-pull-requests-per-team
+ "SELECT count(*) AS total
+ FROM file_pull_request AS fpr
+ JOIN file AS f ON (f.id = fpr.target_file_id)
+ JOIN project AS p ON (p.id = f.project_id)
+ WHERE p.team_id = ?
+ AND fpr.deleted_at IS NULL
+ AND fpr.status = 'open'
+ AND f.deleted_at IS NULL
+ AND p.deleted_at IS NULL")
+
+(defmethod check-quote ::pull-requests-per-team
+ [{:keys [::profile-id ::team-id ::target] :as quote}]
+ (assert (valid-pull-requests-per-team-quote? quote) "invalid quote parameters")
+ (-> quote
+ (assoc ::default (cf/get :quotes-pull-requests-per-team Integer/MAX_VALUE))
+ (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
+ (assoc ::count-sql [sql:get-pull-requests-per-team team-id])
+ (generic-check!)))
+
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: TEAM-ACCESS-REQUESTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj
index 730a2364c9..48bd2a7344 100644
--- a/backend/src/app/tasks/delete_object.clj
+++ b/backend/src/app/tasks/delete_object.clj
@@ -38,6 +38,12 @@
{:id id :file-id file-id :type "snapshot"}
{::db/return-keys false}))
+(def ^:private sql:get-file-branches
+ "SELECT id, branch_file_id
+ FROM file_branch
+ WHERE source_file_id = ?
+ AND deleted_at IS NULL")
+
(defmethod delete-object :file
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}]
(when-let [file (db/get* conn :file {:id id}
@@ -47,6 +53,46 @@
(l/trc :obj "file" :id (str id)
:deleted-at (ct/format-inst deleted-at))
+ ;; Branching: keep the branch metadata in sync when the deleted file
+ ;; IS a branch copy (deleted through paths other than
+ ;; delete-file-branch, e.g. a project deletion) ...
+ (db/update! conn :file-branch
+ {:deleted-at deleted-at}
+ {:branch-file-id id}
+ {::db/return-keys false})
+
+ ;; Pull requests: when the deleted file is a branch copy, any open
+ ;; pull request over it can no longer be reviewed — close it (the
+ ;; row is kept: pull request history outlives its branch, and the
+ ;; review snapshot rows live in this file's file_change/file_data,
+ ;; which this same cascade marks deleted below). When the deleted
+ ;; file is a pull request TARGET, cascade the logical deletion: with
+ ;; main gone the pull request is unreachable forever.
+ (db/update! conn :file-pull-request
+ {:status "closed"
+ :closed-at (ct/now)
+ :updated-at (ct/now)}
+ {:source-file-id id :status "open"}
+ {::db/return-keys false})
+ (db/update! conn :file-pull-request
+ {:deleted-at deleted-at}
+ {:target-file-id id}
+ {::db/return-keys false})
+
+ ;; ... and when it is the SOURCE of branches, cascade the logical
+ ;; deletion to them: branch files are hidden from every listing, so
+ ;; once the source is gone nothing else could ever reach (or GC)
+ ;; them. A branch cannot be a source itself, so this recurses at
+ ;; most one level.
+ (doseq [branch (db/exec! conn [sql:get-file-branches id])]
+ (db/update! conn :file-branch
+ {:deleted-at deleted-at}
+ {:id (:id branch)}
+ {::db/return-keys false})
+ (delete-object cfg {:object :file
+ :id (:branch-file-id branch)
+ :deleted-at deleted-at}))
+
(db/update! conn :file
{:deleted-at deleted-at
:is-shared false}
diff --git a/backend/test/backend_tests/rpc_file_branch_test.clj b/backend/test/backend_tests/rpc_file_branch_test.clj
new file mode 100644
index 0000000000..ac120fd956
--- /dev/null
+++ b/backend/test/backend_tests/rpc_file_branch_test.clj
@@ -0,0 +1,865 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns backend-tests.rpc-file-branch-test
+ (:require
+ [app.common.features :as cfeat]
+ [app.common.time :as ct]
+ [app.common.types.shape :as cts]
+ [app.common.uuid :as uuid]
+ [app.config :as cf]
+ [app.db :as db]
+ [app.rpc :as-alias rpc]
+ [app.storage :as sto]
+ [backend-tests.helpers :as th]
+ [clojure.string :as str]
+ [clojure.test :as t]))
+
+(t/use-fixtures :once th/state-init)
+(t/use-fixtures :each th/database-reset)
+
+(t/deftest create-and-list-branches
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id
+ :is-shared false})
+ branch-file-id (volatile! nil)
+ branch-meta-id (volatile! nil)]
+
+ (t/testing "create branch"
+ (let [params {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "redesign-checkout"
+ :description "A/B test de checkout"}
+ out (th/command! params)]
+ ;; (th/print-result! out)
+ (t/is (nil? (:error out)))
+ (let [result (:result out)]
+ (t/is (uuid? (:id result)))
+ (t/is (uuid? (:branch-file-id result)))
+ (t/is (= (:id file) (:source-file-id result)))
+ (t/is (= "redesign-checkout" (:name result)))
+ (t/is (= "open" (:status result)))
+ (vreset! branch-file-id (:branch-file-id result))
+ (vreset! branch-meta-id (:id result)))))
+
+ (t/testing "branch file is flagged and hidden from project listing"
+ (let [[row] (th/db-query :file {:id @branch-file-id})]
+ (t/is (true? (:is-branch row))))
+
+ (let [out (th/command! {::th/type :get-project-files
+ ::rpc/profile-id (:id profile)
+ :project-id proj-id})]
+ (t/is (nil? (:error out)))
+ (let [rows (:result out)
+ ids (set (map :id rows))
+ src (first (filter #(= (:id %) (:id file)) rows))]
+ (t/is (contains? ids (:id file)))
+ (t/is (not (contains? ids @branch-file-id)))
+ ;; the source file card reports its open branch count
+ (t/is (= 1 (:branches-count src))))))
+
+ (t/testing "list branches"
+ (let [out (th/command! {::th/type :get-file-branches
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)})]
+ (t/is (nil? (:error out)))
+ (let [[row :as result] (:result out)]
+ (t/is (= 1 (count result)))
+ (t/is (= "redesign-checkout" (:name row)))
+ (t/is (= "open" (:status row)))
+ (t/is (= 0 (:ahead row)))
+ (t/is (= 0 (:behind row)))
+ (t/is (= @branch-file-id (:branch-file-id row))))))
+
+ (t/testing "branch context info"
+ ;; the branch file reports its branch metadata
+ (let [out (th/command! {::th/type :get-file-branch-info
+ ::rpc/profile-id (:id profile)
+ :file-id @branch-file-id})
+ info (:result out)]
+ (t/is (nil? (:error out)))
+ (t/is (= "redesign-checkout" (:name info)))
+ (t/is (= "open" (:status info)))
+ (t/is (= (:id file) (:source-file-id info)))
+ (t/is (= (:name file) (:source-name info)))
+ (t/is (= 0 (:ahead info)))
+ (t/is (= 0 (:behind info))))
+ ;; the main file is not a branch -> nil
+ (let [out (th/command! {::th/type :get-file-branch-info
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)})]
+ (t/is (nil? (:error out)))
+ (t/is (nil? (:result out)))))
+
+ (t/testing "merge base snapshot created on main"
+ (let [rows (th/db-query :file-change {:file-id (:id file)})]
+ (t/is (pos? (count rows)))
+ (t/is (some #(= "system" (:created-by %)) rows))))
+
+ (t/testing "diff right after creation has no conflicts"
+ (let [out (th/command! {::th/type :get-branch-diff
+ ::rpc/profile-id (:id profile)
+ :branch-id @branch-meta-id})
+ stats (-> out :result :stats)
+ meta (-> out :result :meta)]
+ (t/is (nil? (:error out)))
+ (t/is (map? stats))
+ ;; base == main == branch at fork -> nothing to merge, no conflicts
+ (t/is (= 0 (:conflicts stats)))
+ ;; the resolution UI gets the "when" of each side
+ (t/is (some? (:main-at meta)))
+ (t/is (some? (:branch-at meta)))
+ (t/is (some? (:base-at meta)))))
+
+ (t/testing "merge branch into main (clean, no-op)"
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile)
+ :branch-id @branch-meta-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= :merged (-> out :result :status))))
+ (let [[row] (th/db-query :file-branch {:id @branch-meta-id})]
+ (t/is (= "merged" (:status row))))))))
+
+(t/deftest merge-applies-branch-changes
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id
+ :is-shared false})
+
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "feature-colors"}))
+ branch-file-id (:branch-file-id create)
+ branch-id (:id create)
+
+ color-id (uuid/random)
+ color {:id color-id :name "Brand" :color "#ff0000" :opacity 1}]
+
+ (t/testing "add a color on the branch"
+ (let [bf (th/db-get :file {:id branch-file-id})
+ out (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id profile)
+ :id branch-file-id
+ :session-id (uuid/random)
+ :revn (:revn bf)
+ :vern (:vern bf)
+ :features cfeat/supported-features
+ :changes [{:type :add-color :color color}]})]
+ (t/is (nil? (:error out)))))
+
+ (t/testing "branch info reports diff-based ahead/behind"
+ ;; one logical change on the branch (the added color), main untouched
+ (let [info (:result (th/command! {::th/type :get-file-branch-info
+ ::rpc/profile-id (:id profile)
+ :file-id branch-file-id}))]
+ (t/is (= 1 (:ahead info)))
+ (t/is (= 0 (:behind info)))
+ (t/is (= 0 (:conflicts info)))))
+
+ (t/testing "branch list reports the same diff-based counts"
+ (let [branches (:result (th/command! {::th/type :get-file-branches
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)}))
+ row (first (filter #(= branch-id (:id %)) branches))]
+ (t/is (= 1 (:ahead row)))
+ (t/is (= 0 (:behind row)))
+ (t/is (= 0 (:conflicts row)))))
+
+ (t/testing "merge brings the new color into main"
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= :merged (-> out :result :status))))
+
+ (let [out (th/command! {::th/type :get-file
+ ::rpc/profile-id (:id profile)
+ :id (:id file)})
+ colors (-> out :result :data :colors)]
+ (t/is (nil? (:error out)))
+ (t/is (contains? colors color-id))
+ (t/is (= "Brand" (get-in colors [color-id :name]))))))))
+
+(t/deftest update-branch-pulls-main-changes
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id
+ :is-shared false})
+
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "long-lived"}))
+ branch-file-id (:branch-file-id create)
+ branch-id (:id create)
+
+ color-id (uuid/random)
+ color {:id color-id :name "MainColor" :color "#00ff00" :opacity 1}]
+
+ (t/testing "add a color on main (after the branch was created)"
+ (let [mf (th/db-get :file {:id (:id file)})
+ out (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id profile)
+ :id (:id file)
+ :session-id (uuid/random)
+ :revn (:revn mf)
+ :vern (:vern mf)
+ :features cfeat/supported-features
+ :changes [{:type :add-color :color color}]})]
+ (t/is (nil? (:error out)))))
+
+ (t/testing "compare in the :main->branch direction shows main's incoming change"
+ (let [diff (:result (th/command! {::th/type :get-branch-diff
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id
+ :direction :main->branch}))
+ added (filterv #(= :added (:status %)) (:changes diff))]
+ (t/is (= 1 (-> diff :stats :added)))
+ (t/is (= color-id (-> added first :id)))))
+
+ (t/testing "update the branch from main"
+ (let [out (th/command! {::th/type :update-branch-from-main
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= :updated (-> out :result :status))))
+
+ (let [out (th/command! {::th/type :get-file
+ ::rpc/profile-id (:id profile)
+ :id branch-file-id})
+ colors (-> out :result :data :colors)]
+ (t/is (contains? colors color-id)))
+
+ ;; merge base repositioned to the current state of main
+ (let [[row] (th/db-query :file-branch {:id branch-id})
+ mf (th/db-get :file {:id (:id file)})]
+ (t/is (= (:revn mf) (:base-revn row))))))))
+
+(t/deftest merge-applies-page-add
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id
+ :is-shared false})
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "feature-page"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)
+ new-page-id (uuid/random)]
+
+ (t/testing "add a page on the branch"
+ (let [bf (th/db-get :file {:id branch-file-id})
+ out (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id profile)
+ :id branch-file-id
+ :session-id (uuid/random)
+ :revn (:revn bf)
+ :vern (:vern bf)
+ :features cfeat/supported-features
+ :changes [{:type :add-page :id new-page-id :name "Branch Page"}]})]
+ (t/is (nil? (:error out)))))
+
+ (t/testing "merge brings the new page into main"
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= :merged (-> out :result :status))))
+
+ (let [out (th/command! {::th/type :get-file
+ ::rpc/profile-id (:id profile)
+ :id (:id file)})
+ pages (-> out :result :data :pages-index)]
+ (t/is (contains? pages new-page-id)))))))
+
+(t/deftest merge-reparents-existing-shape
+ ;; main: a rect at the page root. branch: that rect moved into a new board.
+ ;; after merge main must have the rect INSIDE the board and NOT loose at
+ ;; root (regression: reparenting was applied as :set parent-id, which left
+ ;; the shape duplicated/loose).
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id
+ :is-shared false})
+ page-id (-> (th/command! {::th/type :get-file
+ ::rpc/profile-id (:id profile)
+ :id (:id file)})
+ :result :data :pages first)
+ rect-id (uuid/random)
+ board-id (uuid/random)]
+
+ (t/testing "add a rect at the root of main"
+ (let [mf (th/db-get :file {:id (:id file)})
+ out (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id profile)
+ :id (:id file)
+ :session-id (uuid/random)
+ :revn (:revn mf)
+ :vern (:vern mf)
+ :features cfeat/supported-features
+ :changes [{:type :add-obj
+ :page-id page-id
+ :id rect-id
+ :parent-id uuid/zero
+ :frame-id uuid/zero
+ :obj (cts/setup-shape
+ {:id rect-id :name "Rect" :type :rect
+ :parent-id uuid/zero :frame-id uuid/zero})}]})]
+ (t/is (nil? (:error out)))))
+
+ (let [create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "into-board"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)]
+
+ (t/testing "on the branch, move the rect into a new board"
+ (let [bf (th/db-get :file {:id branch-file-id})
+ out (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id profile)
+ :id branch-file-id
+ :session-id (uuid/random)
+ :revn (:revn bf)
+ :vern (:vern bf)
+ :features cfeat/supported-features
+ :changes [{:type :add-obj
+ :page-id page-id
+ :id board-id
+ :parent-id uuid/zero
+ :frame-id uuid/zero
+ :obj (cts/setup-shape
+ {:id board-id :name "Board" :type :frame
+ :parent-id uuid/zero :frame-id uuid/zero})}
+ {:type :mov-objects
+ :page-id page-id
+ :parent-id board-id
+ :shapes [rect-id]
+ :index 0}]})]
+ (t/is (nil? (:error out)))))
+
+ (t/testing "merge places the rect inside the board, not loose"
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= :merged (-> out :result :status))))
+
+ (let [objects (-> (th/command! {::th/type :get-file
+ ::rpc/profile-id (:id profile)
+ :id (:id file)})
+ :result :data :pages-index (get page-id) :objects)]
+ ;; the board exists and contains the rect
+ (t/is (contains? objects board-id))
+ (t/is (= [rect-id] (get-in objects [board-id :shapes])))
+ ;; the rect points to the board as its parent
+ (t/is (= board-id (get-in objects [rect-id :parent-id])))
+ ;; and is NOT left loose at the root
+ (t/is (not (contains? (set (get-in objects [uuid/zero :shapes])) rect-id)))))))))
+
+(t/deftest merge-component-stays-a-component
+ ;; create a component on a branch, merge it into main, and verify the
+ ;; merged main instance is still a valid component head (component-id set,
+ ;; component-file re-pointed to main) — regression: it was detached.
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id
+ :is-shared false})
+ page-id (-> (th/command! {::th/type :get-file
+ ::rpc/profile-id (:id profile)
+ :id (:id file)})
+ :result :data :pages first)
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "with-component"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)
+ comp-id (uuid/random)
+ mi-id (uuid/random)]
+
+ (t/testing "create a component on the branch"
+ (let [bf (th/db-get :file {:id branch-file-id})
+ out (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id profile)
+ :id branch-file-id
+ :session-id (uuid/random)
+ :revn (:revn bf)
+ :vern (:vern bf)
+ :features cfeat/supported-features
+ :changes
+ [{:type :add-obj
+ :page-id page-id
+ :id mi-id
+ :parent-id uuid/zero
+ :frame-id uuid/zero
+ :obj (-> (cts/setup-shape
+ {:id mi-id :name "Component 01" :type :frame
+ :parent-id uuid/zero :frame-id uuid/zero})
+ (assoc :component-root true
+ :main-instance true
+ :component-id comp-id
+ :component-file branch-file-id))}
+ {:type :add-component
+ :id comp-id
+ :name "Component 01"
+ :path ""
+ :main-instance-id mi-id
+ :main-instance-page page-id}]})]
+ (t/is (nil? (:error out)))))
+
+ (t/testing "merge integrates the component into main as a real head"
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= :merged (-> out :result :status))))
+
+ (let [data (-> (th/command! {::th/type :get-file
+ ::rpc/profile-id (:id profile)
+ :id (:id file)})
+ :result :data)
+ shape (get-in data [:pages-index page-id :objects mi-id])]
+ ;; the component registry row landed in main
+ (t/is (contains? (:components data) comp-id))
+ ;; the main instance is NOT detached: still a head pointing at main
+ (t/is (= comp-id (:component-id shape)))
+ (t/is (= (:id file) (:component-file shape)))
+ (t/is (true? (:main-instance shape)))
+ (t/is (true? (:component-root shape))))))))
+
+(t/deftest branch-lifecycle
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id
+ :is-shared false})
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "tmp"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)]
+
+ (t/testing "rename"
+ (let [out (th/command! {::th/type :update-file-branch
+ ::rpc/profile-id (:id profile)
+ :id branch-id
+ :name "renamed"})]
+ (t/is (nil? (:error out))))
+ (let [[row] (th/db-query :file-branch {:id branch-id})]
+ (t/is (= "renamed" (:name row)))))
+
+ (t/testing "archive hides it from the default list"
+ (let [out (th/command! {::th/type :archive-file-branch
+ ::rpc/profile-id (:id profile)
+ :id branch-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= "archived" (-> out :result :status))))
+ (let [out (th/command! {::th/type :get-file-branches
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)})]
+ (t/is (empty? (:result out))))
+ (let [out (th/command! {::th/type :get-file-branches
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :include-archived true})]
+ (t/is (= 1 (count (:result out))))))
+
+ (t/testing "delete marks branch and branch file deleted"
+ (let [out (th/command! {::th/type :delete-file-branch
+ ::rpc/profile-id (:id profile)
+ :id branch-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= :deleted (-> out :result :status))))
+ (let [[row] (th/db-query :file-branch {:id branch-id})
+ [frow] (th/db-query :file {:id branch-file-id})]
+ (t/is (some? (:deleted-at row)))
+ (t/is (some? (:deleted-at frow))))))))
+
+(t/deftest update-resolves-conflict-to-main
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id
+ :is-shared false})
+ cid (uuid/random)
+ apply-change
+ (fn [file-id change]
+ (let [f (th/db-get :file {:id file-id})]
+ (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id profile)
+ :id file-id
+ :session-id (uuid/random)
+ :revn (:revn f)
+ :vern (:vern f)
+ :features cfeat/supported-features
+ :changes [change]})))]
+
+ ;; color exists on main before branching (base = red)
+ (apply-change (:id file) {:type :add-color :color {:id cid :name "Brand" :color "#ff0000" :opacity 1}})
+
+ (let [create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "b"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)]
+
+ ;; main edits the color -> green; branch edits it -> blue (conflict)
+ (apply-change (:id file) {:type :mod-color :color {:id cid :name "Brand" :color "#00ff00" :opacity 1}})
+ (apply-change branch-file-id {:type :mod-color :color {:id cid :name "Brand" :color "#0000ff" :opacity 1}})
+
+ (t/testing "update without resolutions reports conflicts"
+ (let [out (th/command! {::th/type :update-branch-from-main
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id})]
+ (t/is (= :conflicts (-> out :result :status)))))
+
+ (t/testing "resolving to main brings main's value into the branch"
+ (let [out (th/command! {::th/type :update-branch-from-main
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id
+ :resolutions {cid :main}})]
+ (t/is (nil? (:error out)))
+ (t/is (= :updated (-> out :result :status))))
+ (let [out (th/command! {::th/type :get-file
+ ::rpc/profile-id (:id profile)
+ :id branch-file-id})
+ color (get-in out [:result :data :colors cid])]
+ (t/is (= "#00ff00" (:color color)))))))))
+
+(t/deftest branching-disabled-raises
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id
+ :is-shared false})
+ out (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "nope"})
+ error (:error out)
+ data (ex-data error)]
+ (t/is (th/ex-info? error))
+ (t/is (= :restriction (:type data)))
+ (t/is (= :branching-disabled (:code data)))))
+
+;;; --- Regression tests for the robustness fixes ---
+
+(defn- apply-change*
+ [profile file-id change]
+ (let [f (th/db-get :file {:id file-id})]
+ (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id profile)
+ :id file-id
+ :session-id (uuid/random)
+ :revn (:revn f)
+ :vern (:vern f)
+ :features cfeat/supported-features
+ :changes [change]})))
+
+(t/deftest merge-deletes-branch-by-default-and-releases-base
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id})
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "short-lived"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)
+ cid (uuid/random)]
+
+ (apply-change* profile branch-file-id
+ {:type :add-color :color {:id cid :name "C" :color "#112233" :opacity 1}})
+
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= :merged (-> out :result :status))))
+
+ ;; branch metadata and branch file logically deleted in the same tx
+ (let [[row] (th/db-query :file-branch {:id branch-id})
+ bf (th/db-get :file {:id branch-file-id} {::db/remove-deleted false})]
+ (t/is (= "merged" (:status row)))
+ (t/is (some? (:deleted-at row)))
+ (t/is (some? (:deleted-at bf))))
+
+ ;; the pinned base snapshot is released: no branch-base row with a
+ ;; multi-year retention remains
+ (let [rows (->> (th/db-query :file-change {:file-id (:id file)})
+ (filter #(some-> (:label %) (str/starts-with? "branch-base/"))))]
+ (t/is (seq rows))
+ (t/is (every? (fn [{:keys [deleted-at]}]
+ (and (some? deleted-at)
+ (ct/is-before? deleted-at (ct/in-future {:days 400}))))
+ rows))))))
+
+(t/deftest merge-with-keep-branch-archives-it
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id})
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "kept"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)]
+
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id
+ :keep-branch true})]
+ (t/is (nil? (:error out)))
+ (t/is (= :merged (-> out :result :status))))
+
+ (let [[row] (th/db-query :file-branch {:id branch-id})
+ bf (th/db-get :file {:id branch-file-id})]
+ (t/is (= "merged" (:status row)))
+ (t/is (nil? (:deleted-at row)))
+ (t/is (nil? (:deleted-at bf)))))))
+
+(t/deftest merge-requires-main-edition-permissions
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile1 (th/create-profile* 1 {:is-active true})
+ profile2 (th/create-profile* 2 {:is-active true})
+ proj-id (:default-project-id profile1)
+ file (th/create-file* 1 {:profile-id (:id profile1)
+ :project-id proj-id})
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile1)
+ :file-id (:id file)
+ :name "perm-check"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)]
+
+ ;; profile2 is a team viewer, editor of the BRANCH file but only a
+ ;; viewer of main
+ (th/create-team-role* {:team-id (:default-team-id profile1)
+ :profile-id (:id profile2)
+ :role :viewer})
+ (th/create-file-role* {:file-id branch-file-id
+ :profile-id (:id profile2)
+ :role :editor})
+ (th/create-file-role* {:file-id (:id file)
+ :profile-id (:id profile2)
+ :role :viewer})
+
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile2)
+ :branch-id branch-id})]
+ (t/is (some? (:error out))))
+
+ ;; but profile2 CAN update the branch from main (edits the branch)
+ (let [out (th/command! {::th/type :update-branch-from-main
+ ::rpc/profile-id (:id profile2)
+ :branch-id branch-id})]
+ (t/is (nil? (:error out)))))))
+
+(t/deftest merge-refuses-stale-expected-main-revn
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id})
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "stale"}))
+ branch-id (:id create)]
+
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id
+ :expected-main-revn 9999})
+ error (:error out)]
+ (t/is (some? error))
+ (t/is (= :file-modified (-> error ex-data :code)))))))
+
+(t/deftest revn-gate-survives-update-from-main
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id})
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "gate"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)
+ bid (uuid/random)
+ m1 (uuid/random)
+ m2 (uuid/random)
+ m3 (uuid/random)]
+
+ ;; ONE change on the branch...
+ (apply-change* profile branch-file-id
+ {:type :add-color :color {:id bid :name "B" :color "#0000ff" :opacity 1}})
+ ;; ...while main advances MORE times than the branch (its revn ends up
+ ;; far ahead of the branch's own revn counter)
+ (doseq [[id nm] [[m1 "M1"] [m2 "M2"] [m3 "M3"]]]
+ (apply-change* profile (:id file)
+ {:type :add-color :color {:id id :name nm :color "#00ff00" :opacity 1}}))
+
+ (let [out (th/command! {::th/type :update-branch-from-main
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= :updated (-> out :result :status))))
+
+ ;; after repositioning the base, the branch's own unmerged change must
+ ;; still be reported (before the fix, base-revn was compared against
+ ;; the WRONG counter and ahead collapsed to 0)
+ (let [info (:result (th/command! {::th/type :get-file-branch-info
+ ::rpc/profile-id (:id profile)
+ :file-id branch-file-id}))]
+ (t/is (= 1 (:ahead info)))
+ (t/is (= 0 (:behind info)))))))
+
+(t/deftest cannot-branch-a-branch
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id})
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "level-1"}))]
+ (let [out (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:branch-file-id create)
+ :name "level-2"})
+ error (:error out)]
+ (t/is (some? error))
+ (t/is (= :cannot-branch-a-branch (-> error ex-data :code)))))))
+
+(t/deftest deleting-source-cascades-to-branches
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id})
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "orphan-candidate"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)
+ dt (ct/in-future {:days 7})]
+
+ ;; run the delete-object cascade for the SOURCE file
+ (th/run-task! :delete-object {:object :file :id (:id file) :deleted-at dt})
+
+ (let [[row] (th/db-query :file-branch {:id branch-id})
+ bf (th/db-get :file {:id branch-file-id} {::db/remove-deleted false})]
+ (t/is (some? (:deleted-at row)))
+ (t/is (some? (:deleted-at bf)))))))
+
+(t/deftest merge-copies-branch-media-into-main
+ (with-redefs [cf/flags (conj cf/flags :branching)]
+ (let [profile (th/create-profile* 1 {:is-active true})
+ proj-id (:default-project-id profile)
+ file (th/create-file* 1 {:profile-id (:id profile)
+ :project-id proj-id})
+
+ storage (-> (:app.storage/storage th/*system*)
+ (assoc :app.storage/backend :fs))
+
+ mk-sobj (fn [content]
+ (sto/put-object! storage {::sto/content (sto/content content)
+ :bucket :file-media-object
+ :content-type "image/png"}))
+
+ ;; media present on MAIN before branching (will be paired)
+ sobj1 (mk-sobj "shared-image")
+ fmo1 (th/create-file-media-object* {:file-id (:id file)
+ :name "shared.png"
+ :media-id (:id sobj1)})
+ out1 (apply-change* profile (:id file)
+ {:type :add-media
+ :object {:id (:id fmo1) :name "shared.png"
+ :media-id (:id sobj1)
+ :width 100 :height 100 :mtype "image/png"}})
+
+ create (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name "with-media"}))
+ branch-id (:id create)
+ branch-file-id (:branch-file-id create)
+
+ ;; media ADDED on the branch after forking
+ sobj2 (mk-sobj "branch-only-image")
+ fmo2 (th/create-file-media-object* {:file-id branch-file-id
+ :name "new.png"
+ :media-id (:id sobj2)})
+ out2 (apply-change* profile branch-file-id
+ {:type :add-media
+ :object {:id (:id fmo2) :name "new.png"
+ :media-id (:id sobj2)
+ :width 100 :height 100 :mtype "image/png"}})]
+
+ (t/testing "media changes were applied"
+ (t/is (nil? (:error out1)))
+ (t/is (nil? (:error out2)))
+ (let [bf (th/db-get :file {:id branch-file-id})]
+ (t/is (pos? (:revn bf)))))
+
+ (t/testing "pre-existing media does not show up as branch changes"
+ (let [info (:result (th/command! {::th/type :get-file-branch-info
+ ::rpc/profile-id (:id profile)
+ :file-id branch-file-id}))]
+ ;; only the added media counts; the duplicated (paired) one is
+ ;; normalized away instead of appearing as deleted+added churn
+ (t/is (= 1 (:ahead info)))
+ (t/is (= 0 (:behind info)))))
+
+ (t/testing "merging brings the new media with a MAIN-owned row"
+ (let [out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id
+ :keep-branch true})]
+ (t/is (nil? (:error out)))
+ (t/is (= :merged (-> out :result :status))))
+
+ (let [out (th/command! {::th/type :get-file
+ ::rpc/profile-id (:id profile)
+ :id (:id file)})
+ media (-> out :result :data :media)
+ added (->> (vals media) (filter #(= "new.png" (:name %))) first)]
+ (t/is (some? added))
+ ;; the merged entry references a row owned by MAIN (a copy), not
+ ;; the branch's row (which dies with the branch file)
+ (t/is (not= (:id fmo2) (:id added)))
+ (let [row (th/db-get :file-media-object {:id (:id added)})]
+ (t/is (= (:id file) (:file-id row)))
+ (t/is (= (:id sobj2) (:media-id row)))))))))
diff --git a/backend/test/backend_tests/rpc_file_pull_request_test.clj b/backend/test/backend_tests/rpc_file_pull_request_test.clj
new file mode 100644
index 0000000000..65bfcf8d1f
--- /dev/null
+++ b/backend/test/backend_tests/rpc_file_pull_request_test.clj
@@ -0,0 +1,550 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns backend-tests.rpc-file-pull-request-test
+ (:require
+ [app.common.features :as cfeat]
+ [app.common.time :as ct]
+ [app.common.uuid :as uuid]
+ [app.config :as cf]
+ [app.rpc :as-alias rpc]
+ [backend-tests.helpers :as th]
+ [clojure.string :as str]
+ [clojure.test :as t]))
+
+(t/use-fixtures :once th/state-init)
+(t/use-fixtures :each th/database-reset)
+
+(def ^:private pr-flags
+ (conj cf/flags :branching))
+
+(defn- apply-change*
+ [profile file-id change]
+ (let [f (th/db-get :file {:id file-id})]
+ (th/command! {::th/type :update-file
+ ::rpc/profile-id (:id profile)
+ :id file-id
+ :session-id (uuid/random)
+ :revn (:revn f)
+ :vern (:vern f)
+ :features cfeat/supported-features
+ :changes [change]})))
+
+(defn- add-color*
+ [profile file-id name]
+ (apply-change* profile file-id
+ {:type :add-color
+ :color {:id (uuid/random) :name name :color "#ff0000" :opacity 1}}))
+
+(defn- create-branch*
+ [profile file name]
+ (:result (th/command! {::th/type :create-file-branch
+ ::rpc/profile-id (:id profile)
+ :file-id (:id file)
+ :name name})))
+
+(defn- create-pr*
+ [profile branch-id params]
+ (th/command! (merge {::th/type :create-pull-request
+ ::rpc/profile-id (:id profile)
+ :branch-id branch-id
+ :title "review me"}
+ params)))
+
+(t/deftest create-and-list-pull-requests
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ reviewer (th/create-profile* 2 {:is-active true})
+ proj-id (:default-project-id author)
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id proj-id})]
+
+ (th/create-team-role* {:team-id (:default-team-id author)
+ :profile-id (:id reviewer)
+ :role :editor})
+
+ (let [branch (create-branch* author file "feature")
+ branch-file-id (:branch-file-id branch)
+ pr-id (volatile! nil)]
+
+ (add-color* author branch-file-id "Brand")
+
+ (t/testing "create pull request with a reviewer"
+ (let [out (create-pr* author (:id branch)
+ {:description "please check the brand color"
+ :reviewers [(:id reviewer)]})]
+ (t/is (nil? (:error out)))
+ (let [result (:result out)]
+ (t/is (uuid? (:id result)))
+ (t/is (= branch-file-id (:source-file-id result)))
+ (t/is (= (:id file) (:target-file-id result)))
+ (t/is (= "open" (:status result)))
+ (t/is (= [(:id reviewer)] (:reviewers result)))
+ (vreset! pr-id (:id result)))))
+
+ (t/testing "review snapshot is pinned on the branch file"
+ (let [rows (->> (th/db-query :file-change {:file-id branch-file-id})
+ (filter #(some-> (:label %) (str/starts-with? "pr-review/"))))]
+ (t/is (= 1 (count rows)))
+ (t/is (every? (fn [{:keys [deleted-at]}]
+ (and (some? deleted-at)
+ (ct/is-after? deleted-at (ct/in-future {:days 3000}))))
+ rows))))
+
+ (t/testing "list from the target file"
+ (let [out (th/command! {::th/type :get-file-pull-requests
+ ::rpc/profile-id (:id author)
+ :file-id (:id file)})]
+ (t/is (nil? (:error out)))
+ (let [[row :as result] (:result out)]
+ (t/is (= 1 (count result)))
+ (t/is (= @pr-id (:id row)))
+ (t/is (= "review me" (:title row)))
+ (t/is (= "feature" (:branch-name row)))
+ (t/is (= (:name file) (:target-name row)))
+ (t/is (= "pending" (:review-state row)))
+ (t/is (false? (:outdated row)))
+ ;; the branch carries one change (the added color)
+ (t/is (= 1 (:ahead row)))
+ (t/is (= 0 (:behind row)))
+ (t/is (= 0 (:conflicts row)))
+ (t/is (= 1 (count (:reviews row))))
+ (t/is (= (:id reviewer) (-> row :reviews first :profile-id)))
+ (t/is (= "pending" (-> row :reviews first :state))))))
+
+ (t/testing "list resolves a branch file id to its target"
+ (let [out (th/command! {::th/type :get-file-pull-requests
+ ::rpc/profile-id (:id author)
+ :file-id branch-file-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= 1 (count (:result out))))))
+
+ (t/testing "get single pull request"
+ (let [out (th/command! {::th/type :get-pull-request
+ ::rpc/profile-id (:id reviewer)
+ :id @pr-id})]
+ (t/is (nil? (:error out)))
+ (t/is (= "review me" (-> out :result :title)))))))))
+
+(t/deftest one-open-pull-request-per-branch
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})
+ branch (create-branch* author file "feature")]
+
+ (t/is (nil? (:error (create-pr* author (:id branch) {}))))
+
+ (let [out (create-pr* author (:id branch) {:title "second"})
+ error (:error out)]
+ (t/is (some? error))
+ (t/is (= :pull-request-already-exists (-> error ex-data :code)))))))
+
+(t/deftest create-validates-reviewers-and-permissions
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ stranger (th/create-profile* 2 {:is-active true})
+ viewer (th/create-profile* 3 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})
+ branch (create-branch* author file "feature")]
+
+ (th/create-team-role* {:team-id (:default-team-id author)
+ :profile-id (:id viewer)
+ :role :viewer})
+
+ (t/testing "a reviewer outside the team is refused"
+ (let [out (create-pr* author (:id branch)
+ {:reviewers [(:id stranger)]})
+ error (:error out)]
+ (t/is (some? error))
+ (t/is (= :reviewer-not-team-member (-> error ex-data :code)))))
+
+ (t/testing "the author is silently dropped from the reviewer list"
+ (let [out (create-pr* author (:id branch)
+ {:reviewers [(:id author) (:id viewer)]})]
+ (t/is (nil? (:error out)))
+ (t/is (= [(:id viewer)] (-> out :result :reviewers)))))
+
+ (t/testing "a viewer of the branch file cannot create a pull request"
+ (let [branch2 (create-branch* author file "feature-2")
+ out (create-pr* viewer (:id branch2) {})]
+ (t/is (some? (:error out))))))))
+
+(t/deftest bundle-serves-the-pinned-snapshot
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})
+ branch (create-branch* author file "feature")
+ bfid (:branch-file-id branch)
+ color-1 (uuid/random)
+ color-2 (uuid/random)]
+
+ (apply-change* author bfid
+ {:type :add-color
+ :color {:id color-1 :name "One" :color "#111111" :opacity 1}})
+
+ (let [pr (:result (create-pr* author (:id branch) {}))]
+
+ ;; the branch keeps moving AFTER the pull request was created
+ (apply-change* author bfid
+ {:type :add-color
+ :color {:id color-2 :name "Two" :color "#222222" :opacity 1}})
+
+ (t/testing "the sandbox shows the reviewed state, not the live branch"
+ (let [out (th/command! {::th/type :get-pull-request-bundle
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})
+ result (:result out)
+ colors (-> result :data :colors)]
+ (t/is (nil? (:error out)))
+ (t/is (contains? colors color-1))
+ (t/is (not (contains? colors color-2)))
+ ;; read-only by construction, whatever the caller's role is
+ (t/is (false? (-> result :permissions :can-edit)))))
+
+ (t/testing "the pull request reports the branch moved on"
+ (let [out (th/command! {::th/type :get-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})]
+ (t/is (true? (-> out :result :outdated)))))
+
+ (t/testing "publishing the branch state refreshes the sandbox"
+ (let [out (th/command! {::th/type :update-pull-request-snapshot
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})]
+ (t/is (nil? (:error out)))
+ (t/is (true? (-> out :result :updated))))
+
+ (let [out (th/command! {::th/type :get-pull-request-bundle
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})
+ colors (-> out :result :data :colors)]
+ (t/is (contains? colors color-2)))
+
+ (let [out (th/command! {::th/type :get-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})]
+ (t/is (false? (-> out :result :outdated)))))
+
+ (t/testing "publishing again with no new changes is a no-op"
+ (let [out (th/command! {::th/type :update-pull-request-snapshot
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})]
+ (t/is (nil? (:error out)))
+ (t/is (false? (-> out :result :updated)))))))))
+
+(t/deftest viewer-bundle-serves-the-pinned-snapshot
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})
+ branch (create-branch* author file "feature")
+ bfid (:branch-file-id branch)
+ color-1 (uuid/random)
+ color-2 (uuid/random)]
+
+ (apply-change* author bfid
+ {:type :add-color
+ :color {:id color-1 :name "One" :color "#111111" :opacity 1}})
+
+ (let [pr (:result (create-pr* author (:id branch) {}))]
+
+ ;; the branch keeps moving AFTER the pull request was created
+ (apply-change* author bfid
+ {:type :add-color
+ :color {:id color-2 :name "Two" :color "#222222" :opacity 1}})
+
+ ;; the bundle strips file :data down to pages/components, so the
+ ;; served state is asserted through :revn (the overlay carries the
+ ;; snapshot's revn; the live branch is one save ahead)
+ (t/testing "the viewer bundle shows the reviewed state, read-only"
+ (let [out (th/command! {::th/type :get-view-only-bundle
+ ::rpc/profile-id (:id author)
+ :file-id bfid
+ :pr-id (:id pr)})
+ result (:result out)]
+ (t/is (nil? (:error out)))
+ (t/is (= (:review-revn pr) (-> result :file :revn)))
+ (t/is (false? (-> result :permissions :can-edit)))))
+
+ (t/testing "the viewer bundle without pr-id keeps the live state"
+ (let [out (th/command! {::th/type :get-view-only-bundle
+ ::rpc/profile-id (:id author)
+ :file-id bfid})
+ bf (th/db-get :file {:id bfid})]
+ (t/is (nil? (:error out)))
+ (t/is (= (:revn bf) (-> out :result :file :revn)))
+ (t/is (< (:review-revn pr) (:revn bf)))))
+
+ (t/testing "the viewer bundle refuses a closed pull request"
+ (th/command! {::th/type :close-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})
+ (let [out (th/command! {::th/type :get-view-only-bundle
+ ::rpc/profile-id (:id author)
+ :file-id bfid
+ :pr-id (:id pr)})
+ error (:error out)]
+ (t/is (some? error))
+ (t/is (= :pull-request-not-open (-> error ex-data :code)))))))))
+
+(t/deftest review-lifecycle
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ reviewer (th/create-profile* 2 {:is-active true})
+ other (th/create-profile* 3 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})]
+
+ (th/create-team-role* {:team-id (:default-team-id author)
+ :profile-id (:id reviewer)
+ :role :editor})
+ (th/create-team-role* {:team-id (:default-team-id author)
+ :profile-id (:id other)
+ :role :editor})
+
+ (let [branch (create-branch* author file "feature")
+ bfid (:branch-file-id branch)
+ _ (add-color* author bfid "Brand")
+ pr (:result (create-pr* author (:id branch)
+ {:reviewers [(:id reviewer)]}))]
+
+ (t/testing "a non-assigned member cannot submit a review"
+ (let [out (th/command! {::th/type :submit-pull-request-review
+ ::rpc/profile-id (:id other)
+ :id (:id pr)
+ :state "approved"})
+ error (:error out)]
+ (t/is (some? error))
+ (t/is (= :not-a-reviewer (-> error ex-data :code)))))
+
+ (t/testing "an approval flips the aggregate review state"
+ (let [out (th/command! {::th/type :submit-pull-request-review
+ ::rpc/profile-id (:id reviewer)
+ :id (:id pr)
+ :state "approved"
+ :comment "ship it"})]
+ (t/is (nil? (:error out))))
+ (let [out (th/command! {::th/type :get-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})
+ row (:result out)]
+ (t/is (= "approved" (:review-state row)))
+ (t/is (false? (-> row :reviews first :stale)))))
+
+ (t/testing "publishing new changes makes the verdict stale"
+ (add-color* author bfid "Another")
+ (let [out (th/command! {::th/type :update-pull-request-snapshot
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})]
+ (t/is (nil? (:error out))))
+ (let [row (:result (th/command! {::th/type :get-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)}))]
+ ;; the verdict is kept for the history but no longer current
+ (t/is (= "in-review" (:review-state row)))
+ (t/is (true? (-> row :reviews first :stale)))))
+
+ (t/testing "requesting changes beats an approval"
+ (let [out (th/command! {::th/type :submit-pull-request-review
+ ::rpc/profile-id (:id reviewer)
+ :id (:id pr)
+ :state "changes-requested"
+ :comment "the second color is off-brand"})]
+ (t/is (nil? (:error out))))
+ (let [row (:result (th/command! {::th/type :get-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)}))]
+ (t/is (= "changes-requested" (:review-state row)))))))))
+
+(t/deftest update-pull-request-metadata-and-reviewers
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ reviewer-1 (th/create-profile* 2 {:is-active true})
+ reviewer-2 (th/create-profile* 3 {:is-active true})
+ editor (th/create-profile* 4 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})]
+
+ (doseq [[profile role] [[reviewer-1 :editor] [reviewer-2 :editor] [editor :editor]]]
+ (th/create-team-role* {:team-id (:default-team-id author)
+ :profile-id (:id profile)
+ :role role}))
+
+ (let [branch (create-branch* author file "feature")
+ pr (:result (create-pr* author (:id branch)
+ {:reviewers [(:id reviewer-1)]}))]
+
+ (t/testing "a plain editor (not author, not admin) cannot manage it"
+ (let [out (th/command! {::th/type :update-pull-request
+ ::rpc/profile-id (:id editor)
+ :id (:id pr)
+ :title "hijacked"})]
+ (t/is (some? (:error out)))))
+
+ (t/testing "the author can replace title, description and reviewers"
+ (let [out (th/command! {::th/type :update-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)
+ :title "better title"
+ :description "more context"
+ :reviewers [(:id reviewer-2)]})]
+ (t/is (nil? (:error out))))
+ (let [row (:result (th/command! {::th/type :get-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)}))]
+ (t/is (= "better title" (:title row)))
+ (t/is (= "more context" (:description row)))
+ (t/is (= [(:id reviewer-2)] (mapv :profile-id (:reviews row))))))
+
+ (t/testing "a removed reviewer can no longer submit"
+ (let [out (th/command! {::th/type :submit-pull-request-review
+ ::rpc/profile-id (:id reviewer-1)
+ :id (:id pr)
+ :state "approved"})
+ error (:error out)]
+ (t/is (some? error))
+ (t/is (= :not-a-reviewer (-> error ex-data :code)))))))))
+
+(t/deftest close-and-reopen-pull-request
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ editor (th/create-profile* 2 {:is-active true})
+ admin (th/create-profile* 3 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})]
+
+ (th/create-team-role* {:team-id (:default-team-id author)
+ :profile-id (:id editor)
+ :role :editor})
+ (th/create-team-role* {:team-id (:default-team-id author)
+ :profile-id (:id admin)
+ :role :admin})
+
+ (let [branch (create-branch* author file "feature")
+ bfid (:branch-file-id branch)
+ pr (:result (create-pr* author (:id branch) {}))]
+
+ (t/testing "a plain editor cannot close someone else's pull request"
+ (let [out (th/command! {::th/type :close-pull-request
+ ::rpc/profile-id (:id editor)
+ :id (:id pr)})]
+ (t/is (some? (:error out)))))
+
+ (t/testing "a team admin can close it"
+ (let [out (th/command! {::th/type :close-pull-request
+ ::rpc/profile-id (:id admin)
+ :id (:id pr)})]
+ (t/is (nil? (:error out)))
+ (t/is (= "closed" (-> out :result :status)))))
+
+ (t/testing "the sandbox stops resolving once closed"
+ (let [out (th/command! {::th/type :get-pull-request-bundle
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})
+ error (:error out)]
+ (t/is (some? error))
+ (t/is (= :pull-request-not-open (-> error ex-data :code)))))
+
+ (t/testing "the review snapshot pin is released on close"
+ (let [rows (->> (th/db-query :file-change {:file-id bfid})
+ (filter #(some-> (:label %) (str/starts-with? "pr-review/"))))]
+ (t/is (seq rows))
+ (t/is (every? (fn [{:keys [deleted-at]}]
+ (and (some? deleted-at)
+ (ct/is-before? deleted-at (ct/in-future {:days 400}))))
+ rows))))
+
+ (t/testing "the author can reopen it, re-pinned at the current head"
+ (add-color* author bfid "Late")
+ (let [out (th/command! {::th/type :reopen-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})
+ bf (th/db-get :file {:id bfid})]
+ (t/is (nil? (:error out)))
+ (t/is (= "open" (-> out :result :status)))
+ (t/is (= (:revn bf) (-> out :result :review-revn)))))))))
+
+(t/deftest branch-lifecycle-closes-pull-requests
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})]
+
+ (t/testing "merging the branch marks the pull request merged"
+ (let [branch (create-branch* author file "merge-me")
+ _ (add-color* author (:branch-file-id branch) "Brand")
+ pr (:result (create-pr* author (:id branch) {}))
+ out (th/command! {::th/type :merge-file-branch
+ ::rpc/profile-id (:id author)
+ :branch-id (:id branch)})]
+ (t/is (nil? (:error out)))
+ (let [[row] (th/db-query :file-pull-request {:id (:id pr)})]
+ (t/is (= "merged" (:status row)))
+ (t/is (some? (:closed-at row)))
+ ;; the row survives as history even though the branch is gone
+ (t/is (nil? (:deleted-at row))))))
+
+ (t/testing "deleting the branch closes the pull request"
+ (let [branch (create-branch* author file "delete-me")
+ pr (:result (create-pr* author (:id branch) {}))
+ out (th/command! {::th/type :delete-file-branch
+ ::rpc/profile-id (:id author)
+ :id (:id branch)})]
+ (t/is (nil? (:error out)))
+ (let [[row] (th/db-query :file-pull-request {:id (:id pr)})]
+ (t/is (= "closed" (:status row))))))
+
+ (t/testing "archiving the branch closes the pull request"
+ (let [branch (create-branch* author file "archive-me")
+ pr (:result (create-pr* author (:id branch) {}))
+ out (th/command! {::th/type :archive-file-branch
+ ::rpc/profile-id (:id author)
+ :id (:id branch)})]
+ (t/is (nil? (:error out)))
+ (let [[row] (th/db-query :file-pull-request {:id (:id pr)})]
+ (t/is (= "closed" (:status row)))))))))
+
+(t/deftest closed-pull-requests-listing
+ (with-redefs [cf/flags pr-flags]
+ (let [author (th/create-profile* 1 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})
+ branch (create-branch* author file "feature")
+ pr (:result (create-pr* author (:id branch) {}))]
+
+ (th/command! {::th/type :close-pull-request
+ ::rpc/profile-id (:id author)
+ :id (:id pr)})
+
+ (t/testing "closed pull requests are hidden by default"
+ (let [out (th/command! {::th/type :get-file-pull-requests
+ ::rpc/profile-id (:id author)
+ :file-id (:id file)})]
+ (t/is (nil? (:error out)))
+ (t/is (= 0 (count (:result out))))))
+
+ (t/testing "and visible with include-closed"
+ (let [out (th/command! {::th/type :get-file-pull-requests
+ ::rpc/profile-id (:id author)
+ :file-id (:id file)
+ :include-closed true})]
+ (t/is (nil? (:error out)))
+ (t/is (= 1 (count (:result out))))
+ (t/is (= "closed" (-> out :result first :status))))))))
+
+(t/deftest branching-flag-gates-pull-request-commands
+ (let [author (th/create-profile* 1 {:is-active true})
+ file (th/create-file* 1 {:profile-id (:id author)
+ :project-id (:default-project-id author)})
+ branch (with-redefs [cf/flags pr-flags]
+ (create-branch* author file "feature"))
+ out (create-pr* author (:id branch) {})
+ error (:error out)]
+ (t/is (some? error))
+ (t/is (= :branching-disabled (-> error ex-data :code)))))
diff --git a/common/src/app/common/files/branch_merge.cljc b/common/src/app/common/files/branch_merge.cljc
new file mode 100644
index 0000000000..bb10f3328b
--- /dev/null
+++ b/common/src/app/common/files/branch_merge.cljc
@@ -0,0 +1,1173 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns app.common.files.branch-merge
+ "Three-way, entity-level merge/diff engine for file branching.
+
+ Phase 2 scope: read-only diff. `compute-merge` takes the file `:data`
+ of the merge base, main and branch and returns a serializable summary
+ of what the branch changes relative to main, plus the set of
+ conflicting entities. It never mutates the blob; translating the
+ result into `changes` (for an actual merge) and token diffing arrive
+ in later phases.
+
+ Direction `:branch->main` (merge/compare) treats main as `theirs` and
+ branch as `ours`; `:main->branch` (update from main) swaps them."
+ (:require
+ [app.common.data :as d]
+ [app.common.files.helpers :as cfh]
+ [app.common.types.component :as ctk]
+ [app.common.types.tokens-lib :as ctob]
+ [app.common.uuid :as uuid]
+ [clojure.set :as set]
+ [clojure.string :as str]))
+
+(defn- entity-label
+ [kind v]
+ (or (:name v)
+ (some-> (:id v) str)
+ (name kind)))
+
+(defn- shallow-attr-diff
+ "Map of attr -> {:main v :branch v} for the keys whose values differ
+ between the `theirs` and `ours` entity maps. Powers the
+ \"property changes\" detail in the compare view. Returns `{}` for
+ non-map entities (e.g. order/name tuples)."
+ [theirs ours]
+ (if (and (map? theirs) (map? ours))
+ (let [ks (set/union (set (keys theirs)) (set (keys ours)))]
+ (reduce (fn [acc k]
+ (let [tv (get theirs k)
+ ov (get ours k)]
+ (if (= tv ov)
+ acc
+ (assoc acc k {:main tv :branch ov}))))
+ {}
+ ks))
+ {}))
+
+;; --- Per-property conflict resolution ---
+;;
+;; A resolution value for a conflicting entity is one of:
+;; :main keep main entirely
+;; :branch take branch entirely
+;; {attr -> side} per-property: choose :main or :branch for each
+;; changed attribute independently
+;; The per-attr map only applies to conflicts that carry `:changed-attrs`
+;; (modify-modify / add-add). Structural conflicts (delete/modify, order,
+;; presence, …) stay keyword-only.
+
+(defn- attr-side
+ "Resolved side (`:main`/`:branch`) for attribute `k` under resolution
+ `res`. `:branch` keyword -> branch for every attr; a map -> its per-attr
+ choice (defaulting to `:main`); anything else (`:main`, nil) -> main."
+ [res k]
+ (cond
+ (= res :branch) :branch
+ (map? res) (if (= :branch (get res k)) :branch :main)
+ :else :main))
+
+(defn- merge-attrs
+ "Entity to apply for a per-attr resolution: start from main (`t`) and
+ overlay branch's value for each differing attr resolved to `:branch`.
+ For `res = :branch` this equals `o`; for `:main`/nil it equals `t`."
+ [t o res]
+ (reduce (fn [acc [k {:keys [branch]}]]
+ (if (= :branch (attr-side res k))
+ (assoc acc k branch)
+ acc))
+ t
+ (shallow-attr-diff t o)))
+
+(defn conflict-resolved?
+ "True when `res` fully resolves `conflict`. A keyword `:main`/`:branch`
+ always resolves it; a per-attr map resolves it only when it carries a
+ choice for every key in the conflict's `:changed-attrs` (and the
+ conflict actually has changed-attrs)."
+ [conflict res]
+ (cond
+ (contains? #{:main :branch} res) true
+ (map? res) (let [attrs (keys (:changed-attrs conflict))]
+ (boolean (and (seq attrs)
+ (every? #(contains? res %) attrs))))
+ :else false))
+
+(def ^:private shape-ignored-attrs
+ "Purely derived/structural shape attrs excluded from the shape diff
+ CLASSIFICATION (and thus from the compare summary): children
+ membership/order (`:shapes`, redundant with the child's own add/move),
+ the sync flag (`:touched`) and geometry caches (`:selrect`, `:points`,
+ recomputed from position/size). Containment (`:parent-id`/`:frame-id`)
+ is deliberately NOT here: reparenting a layer into a board is a real,
+ user-meaningful change worth surfacing.
+
+ Classifying on stripped shapes is what prevents FALSE conflicts: both
+ sides adding children to the same frame only differ on `:shapes`, and a
+ library sync on main only flips `:touched` — neither is a user change,
+ so neither may turn a clean branch edit into a modify-modify/delete
+ conflict. The merge still applies the real values: containment through
+ add/del/move ops, geometry caches piggybacked on the geometry attrs
+ (see `page-shape-changes`)."
+ #{:shapes :touched :selrect :points})
+
+(defn- strip-shapes
+ "Remove `shape-ignored-attrs` from every shape of an `:objects` map, for
+ diff classification purposes."
+ [objects]
+ (persistent!
+ (reduce-kv (fn [acc id shape]
+ (assoc! acc id (apply dissoc shape shape-ignored-attrs)))
+ (transient {})
+ (or objects {}))))
+
+(defn- shape-display-meta
+ "Display-only metadata for a shape diff entry — its type and component
+ nature — so the compare view can pick a type-accurate icon and label
+ instead of a generic one."
+ [shape]
+ (when (map? shape)
+ (cond-> {:shape-type (:type shape)}
+ (ctk/main-instance? shape) (assoc :component? true)
+ (and (ctk/instance-head? shape)
+ (not (ctk/main-instance? shape))) (assoc :component-copy? true)
+ (ctk/is-variant? shape) (assoc :variant? true)
+ (= :bool (:type shape)) (assoc :bool-type (:bool-type shape))
+ (:masked-group shape) (assoc :masked? true))))
+
+(defn three-way-entities
+ "Diff one indexed entity collection (id->value) across base/theirs/ours.
+
+ Returns `{:changes [..] :conflicts [..]}` where changes are the
+ branch's net additions/modifications/deletions that apply cleanly to
+ main, and conflicts are entities both sides diverged on.
+
+ `ctx`: `{:kind :page-id }`. Optional display
+ knobs (do NOT affect the actual merge, only this summary):
+ `:ignore-ids` ids skipped entirely (e.g. the page root frame);
+ `:ignore-attrs` attr keys stripped from `:changed-attrs` (structural
+ noise like `:shapes` whose merge is driven by other ops);
+ `:drop-empty-modified?` when a `:modified` entry's `:changed-attrs`
+ becomes empty after stripping, omit it altogether."
+ [base theirs ours {:keys [kind ignore-ids ignore-attrs drop-empty-modified?] :as ctx}]
+ (let [extras (dissoc ctx :kind :ignore-ids :ignore-attrs :drop-empty-modified?)
+ attr-diff (fn [t o]
+ (let [d (shallow-attr-diff t o)]
+ (if (seq ignore-attrs) (apply dissoc d ignore-attrs) d)))
+ ids (cond-> (set/union (set (keys base)) (set (keys theirs)) (set (keys ours)))
+ (seq ignore-ids) (set/difference (set ignore-ids)))
+ mk (fn [status extra]
+ (merge extra extras {:kind kind :status status}))]
+ (reduce
+ (fn [acc id]
+ (let [b (get base id)
+ t (get theirs id)
+ o (get ours id)
+ in-b? (contains? base id)
+ in-t? (contains? theirs id)
+ in-o? (contains? ours id)]
+ (cond
+ ;; --- deletions ---
+ (and in-b? (not in-o?) (not in-t?)) ; deleted in both
+ acc
+
+ (and in-b? (not in-o?) (= b t)) ; deleted in branch, main intact
+ (update acc :changes conj (mk :deleted {:id id :label (entity-label kind t)}))
+
+ (and in-b? (not in-t?) (= b o)) ; deleted in main, branch intact
+ acc
+
+ (and in-b? (not in-o?) (not= b t)) ; delete (branch) / modify (main)
+ (update acc :conflicts conj (mk :conflict {:id id :reason :delete-modify
+ :label (entity-label kind t)
+ :base b :main t :branch nil}))
+
+ (and in-b? (not in-t?) (not= b o)) ; modify (branch) / delete (main)
+ (update acc :conflicts conj (mk :conflict {:id id :reason :modify-delete
+ :label (entity-label kind o)
+ :base b :main nil :branch o}))
+
+ ;; --- additions ---
+ (and (not in-b?) in-o? (not in-t?)) ; new in branch
+ (update acc :changes conj (mk :added {:id id :label (entity-label kind o)}))
+
+ (and (not in-b?) in-t? (not in-o?)) ; new in main only
+ acc
+
+ (and (not in-b?) in-o? in-t? (= o t)) ; both added the same
+ acc
+
+ (and (not in-b?) in-o? in-t? (not= o t)) ; both added, different
+ (update acc :conflicts conj (mk :conflict {:id id :reason :add-add
+ :label (entity-label kind o)
+ :changed-attrs (attr-diff t o)
+ :base nil :main t :branch o}))
+
+ ;; --- present in all three ---
+ (= o b) ; branch didn't touch -> main wins
+ acc
+
+ (= t b) ; main didn't touch, branch did
+ (let [ca (attr-diff t o)]
+ (if (and drop-empty-modified? (map? o) (empty? ca))
+ acc
+ (update acc :changes conj (mk :modified {:id id :label (entity-label kind o)
+ :changed-attrs ca}))))
+
+ (= o t) ; both reached the same value
+ acc
+
+ :else ; both diverged differently
+ (update acc :conflicts conj (mk :conflict {:id id :reason :modify-modify
+ :label (entity-label kind o)
+ :changed-attrs (attr-diff t o)
+ :base b :main t :branch o})))))
+ {:changes [] :conflicts []}
+ ids)))
+
+(defn- merge-results
+ [results]
+ {:changes (into [] (mapcat :changes) results)
+ :conflicts (into [] (mapcat :conflicts) results)})
+
+(defn- presence-only
+ "Keep only the existence-level results of a CONTENT-valued presence diff:
+ added/deleted changes and delete conflicts. Content modifications of
+ entities present on both sides are covered by the granular passes, so
+ they are dropped here. Diffing presence over content (instead of `true`)
+ is what turns \"one side deleted it, the other edited it\" into a proper
+ delete conflict instead of a silent clean delete."
+ [{:keys [changes conflicts]}]
+ {:changes (filterv #(contains? #{:added :deleted} (:status %)) changes)
+ :conflicts (filterv #(contains? #{:delete-modify :modify-delete} (:reason %)) conflicts)})
+
+(defn- page-meta
+ "Page attrs mergeable via `:mod-page` (name/background/pixel-grid)."
+ [page]
+ (select-keys page [:name :background :pixel-grid-color :pixel-grid-opacity]))
+
+(defn- page-extra
+ "Residual page attrs that no pass handles — kept as `:page-attrs` to be
+ refused (never silently dropped). Objects, name/background/grid, guides,
+ flows, default-grids and plugin-data have their own passes; `:index` is
+ derived from page order; comment-thread-positions deliberately stay on
+ the branch (comments are not migrated, like Figma)."
+ [page]
+ (dissoc page :objects :id :name :background :pixel-grid-color :pixel-grid-opacity
+ :guides :flows :default-grids :plugin-data
+ :index :comment-thread-positions))
+
+(defn- page-content
+ "Normalized page value for the presence pass: the page without derived
+ attrs (`:index`, comment positions — comments are not migrated) and with
+ its shapes stripped of derived attrs, so only user-meaningful content
+ can turn a page deletion into a delete conflict."
+ [page]
+ (some-> page
+ (dissoc :index :comment-thread-positions)
+ (update :objects strip-shapes)))
+
+(defn- page-content-map
+ [pages-index ids]
+ (into {} (map (fn [id] [id (page-content (get pages-index id))])) ids))
+
+(defn- slim-page
+ "Compact page summary for conflict payloads (a full page embeds every
+ shape three times over the wire)."
+ [page]
+ (when page
+ {:id (:id page) :name (:name page) :shapes (count (:objects page))}))
+
+(defn- slim-conflict-sides
+ [slim-fn conflicts]
+ (mapv (fn [c] (-> c (update :base slim-fn) (update :main slim-fn) (update :branch slim-fn)))
+ conflicts))
+
+(defn- flatten-plugin-data
+ "{namespace {key value}} -> {[namespace key] value} for diffing."
+ [pd]
+ (persistent!
+ (reduce-kv (fn [acc ns kvs]
+ (reduce-kv (fn [acc k v] (assoc! acc [ns k] v)) acc kvs))
+ (transient {})
+ (or pd {}))))
+
+(defn- diff-pages
+ [base theirs ours]
+ (let [bpi (or (:pages-index base) {}) tpi (or (:pages-index theirs) {}) opi (or (:pages-index ours) {})
+ bids (set (keys bpi)) tids (set (keys tpi)) oids (set (keys opi))
+ common (set/intersection bids tids oids)
+
+ ;; page add/delete (mergeable: :page). Presence is diffed over the
+ ;; page CONTENT so that deleting a page the other side edited raises
+ ;; a delete conflict instead of silently dropping those edits;
+ ;; content-only modifications are filtered out (granular passes own
+ ;; them) and conflict payloads are slimmed (a full page is huge).
+ presence (-> (three-way-entities (page-content-map bpi bids)
+ (page-content-map tpi tids)
+ (page-content-map opi oids)
+ {:kind :page})
+ (presence-only)
+ (update :conflicts #(slim-conflict-sides slim-page %)))
+ ;; page name/background/grid on common pages (mergeable: :page via mod-page)
+ meta-map (fn [pi] (into {} (map (fn [id] [id (page-meta (get pi id))])) common))
+ meta-diff (three-way-entities (meta-map bpi) (meta-map tpi) (meta-map opi) {:kind :page})
+ ;; residual page attrs on common pages (NOT mergeable -> refused, never dropped)
+ extra-map (fn [pi] (into {} (map (fn [id] [id (page-extra (get pi id))])) common))
+ extra-diff (three-way-entities (extra-map bpi) (extra-map tpi) (extra-map opi) {:kind :page-attrs})
+ ;; page order on common pages. The entity id is `:page-order` (NOT a
+ ;; bare `:order`) so its conflict resolution cannot collide with the
+ ;; token-set-order one and matches what `compute-changes` reads.
+ order-of (fn [data] (filterv common (or (:pages data) [])))
+ order (three-way-entities {:page-order (order-of base)} {:page-order (order-of theirs)} {:page-order (order-of ours)}
+ {:kind :page-order})
+ ;; guides / flows per common page (mergeable: :page-guide / :page-flow)
+ sub-of (fn [data pid k] (get-in data [:pages-index pid k] {}))
+ guides-diffs (map (fn [pid]
+ (three-way-entities (sub-of base pid :guides)
+ (sub-of theirs pid :guides)
+ (sub-of ours pid :guides)
+ {:kind :page-guide :page-id pid}))
+ common)
+ flows-diffs (map (fn [pid]
+ (three-way-entities (sub-of base pid :flows)
+ (sub-of theirs pid :flows)
+ (sub-of ours pid :flows)
+ {:kind :page-flow :page-id pid}))
+ common)
+ ;; default-grids per common page (mergeable: :page-grid -> :set-default-grid)
+ grids-diffs (map (fn [pid]
+ (three-way-entities (sub-of base pid :default-grids)
+ (sub-of theirs pid :default-grids)
+ (sub-of ours pid :default-grids)
+ {:kind :page-grid :page-id pid}))
+ common)
+ ;; page-level plugin-data per common page (mergeable: :page-plugin -> :set-plugin-data)
+ plugin-of (fn [data pid] (flatten-plugin-data (get-in data [:pages-index pid :plugin-data] {})))
+ plugins-diffs (map (fn [pid]
+ (three-way-entities (plugin-of base pid)
+ (plugin-of theirs pid)
+ (plugin-of ours pid)
+ {:kind :page-plugin :page-id pid}))
+ common)
+ ;; objects (shapes) on common pages (mergeable: :shape). The page
+ ;; root frame (uuid/zero) is skipped and the shapes are diffed
+ ;; STRIPPED of derived attrs (`shape-ignored-attrs`) so that pure
+ ;; `:shapes`/`:touched`/geometry-cache churn neither shows up as a
+ ;; change nor manufactures false conflicts (their merge is driven
+ ;; by add/del/move ops, not by these values).
+ obj-diffs (map (fn [pid]
+ (let [bo (get-in base [:pages-index pid :objects] {})
+ to (get-in theirs [:pages-index pid :objects] {})
+ oo (get-in ours [:pages-index pid :objects] {})
+ res (three-way-entities (strip-shapes bo) (strip-shapes to) (strip-shapes oo)
+ {:kind :shape :page-id pid
+ :ignore-ids #{uuid/zero}})
+ ;; enrich each entry with the shape's type/component
+ ;; nature, read from whichever side still has it
+ enrich (fn [e]
+ (merge e (shape-display-meta
+ (or (get oo (:id e))
+ (get to (:id e))
+ (get bo (:id e))))))
+ ;; conflict payloads must carry the FULL shapes
+ ;; (the diff classified stripped copies, but the
+ ;; conflict UI renders real previews that need
+ ;; :selrect/:points/:transform)
+ rehydrate (fn [e]
+ (assoc e
+ :base (get bo (:id e))
+ :main (get to (:id e))
+ :branch (get oo (:id e))))]
+ {:changes (mapv enrich (:changes res))
+ :conflicts (mapv (comp rehydrate enrich) (:conflicts res))}))
+ common)]
+ (merge-results (concat [presence meta-diff extra-diff order]
+ guides-diffs flows-diffs grids-diffs plugins-diffs obj-diffs))))
+
+;; --- Tokens ---
+;;
+;; Token *values* (tokens within an existing set) are mergeable (kind
+;; :token -> :set-token). Structural token changes (adding/renaming sets,
+;; themes, active-theme/active-set toggles) are surfaced with
+;; non-mergeable kinds (:token-set, :token-theme, :token-active-themes)
+;; so the merge refuses them rather than dropping them silently — a full
+;; structural token merge is a later step.
+
+(defn- lib-set-ids
+ [lib]
+ (if lib (into #{} (map ctob/get-id) (ctob/get-sets lib)) #{}))
+
+(defn- lib-set-order
+ [lib]
+ (if lib (mapv ctob/get-id (ctob/get-sets lib)) []))
+
+(defn- lib-set-meta
+ "set-id -> [name description] for each set."
+ [lib]
+ (if lib
+ (into {} (map (fn [s] [(ctob/get-id s) [(ctob/get-name s) (ctob/get-description s)]]))
+ (ctob/get-sets lib))
+ {}))
+
+(declare lib-tokens-by-id)
+
+(defn- set-content
+ "Normalized token-set value (metadata + tokens) for the presence pass, so
+ deleting a set the other side edited raises a delete conflict."
+ [lib set-id]
+ (when (and lib (ctob/get-set lib set-id))
+ (let [s (ctob/get-set lib set-id)]
+ {:id set-id
+ :name (ctob/get-name s)
+ :description (ctob/get-description s)
+ :tokens (lib-tokens-by-id lib set-id)})))
+
+(defn- set-content-map
+ [lib]
+ (into {} (map (fn [sid] [sid (set-content lib sid)]))
+ (if lib (map ctob/get-id (ctob/get-sets lib)) [])))
+
+(defn- slim-set
+ "Compact token-set summary for conflict payloads."
+ [s]
+ (when s
+ {:id (:id s) :name (:name s) :tokens (count (:tokens s))}))
+
+(defn- set-restore-attrs
+ "Attrs to (re)create a set in the target. A brand-new set (absent from
+ the base) carries metadata only — its tokens are added by the per-token
+ pass. A RESTORED set (present in the base, deleted on the other side)
+ must carry its tokens too: the per-token pass skips sets deleted on
+ either side."
+ [lib base-lib set-id]
+ (let [s (ctob/get-set lib set-id)
+ attrs {:id set-id :name (ctob/get-name s) :description (ctob/get-description s)}]
+ (if (and base-lib (ctob/get-set base-lib set-id))
+ (assoc attrs :tokens (ctob/get-tokens lib set-id))
+ attrs)))
+
+(defn- set-rename-attrs
+ "Attrs to rename a set: take branch's name/description but keep MAIN's
+ tokens — emitted before the per-token pass, which then layers branch's
+ token edits on top. `:set-token-set`/`update-set` relocates the set and
+ updates theme references for the new name."
+ [main-lib branch-lib set-id]
+ (let [bs (ctob/get-set branch-lib set-id)]
+ {:id set-id
+ :name (ctob/get-name bs)
+ :description (ctob/get-description bs)
+ :tokens (ctob/get-tokens main-lib set-id)}))
+
+(defn- set-meta-of
+ [lib set-id]
+ (let [s (ctob/get-set lib set-id)]
+ [(ctob/get-name s) (ctob/get-description s)]))
+
+(defn- lib-tokens-by-id
+ "token-id -> token (plain map) for a single set."
+ [lib set-id]
+ (if (and lib (ctob/get-set lib set-id))
+ (into {} (map (fn [t] [(:id t) (into {} t)])) (vals (ctob/get-tokens lib set-id)))
+ {}))
+
+(defn- lib-themes
+ "theme-id -> theme (plain map), excluding the internal hidden theme."
+ [lib]
+ (if lib
+ (into {} (comp (remove #(= (:id %) ctob/hidden-theme-id))
+ (map (fn [t] [(:id t) (into {} t)])))
+ (ctob/get-themes lib))
+ {}))
+
+(defn- lib-active-paths
+ "Active theme paths (mergeable via :set-active-token-themes)."
+ [lib]
+ (if lib (set (ctob/get-active-theme-paths lib)) #{}))
+
+(defn- lib-hidden-sets
+ "The hidden theme's active sets (active-set toggles)."
+ [lib]
+ (some-> lib (ctob/get-theme ctob/hidden-theme-id) :sets set))
+
+(defn- hidden-theme-map
+ "The hidden theme as a plain map (for :set-token-theme)."
+ [lib]
+ (some->> (when lib (ctob/get-theme lib ctob/hidden-theme-id)) (into {})))
+
+(defn- set-order-by-id
+ "Set ids in their stored order, filtered to `ids`."
+ [lib ids]
+ (filterv ids (lib-set-order lib)))
+
+(defn- set-name->path
+ "Split a token-set name into its path vector (sets are referenced by
+ path, separator \"/\")."
+ [name]
+ (str/split name #"/"))
+
+(defn- diff-tokens
+ [base theirs ours]
+ (let [bl (:tokens-lib base) tl (:tokens-lib theirs) ol (:tokens-lib ours)
+ bids (lib-set-ids bl) tids (lib-set-ids tl) oids (lib-set-ids ol)
+ common (set/intersection bids tids oids)
+
+ ;; set add/delete (mergeable: :token-set). Diffed over the set
+ ;; CONTENT so deleting a set the other side edited raises a delete
+ ;; conflict (see `presence-only`); payloads slimmed for the wire.
+ presence (-> (three-way-entities (set-content-map bl) (set-content-map tl) (set-content-map ol)
+ {:kind :token-set})
+ (presence-only)
+ (update :conflicts #(slim-conflict-sides slim-set %)))
+ ;; set rename/description on common sets (NOT yet mergeable)
+ rename (three-way-entities (select-keys (lib-set-meta bl) common)
+ (select-keys (lib-set-meta tl) common)
+ (select-keys (lib-set-meta ol) common)
+ {:kind :token-set-rename})
+ ;; set order on common sets. Entity id `:token-set-order` (NOT a bare
+ ;; `:order`) so its resolution cannot collide with the page-order one.
+ order-of (fn [lib] (filterv common (lib-set-order lib)))
+ order (three-way-entities {:token-set-order (order-of bl)} {:token-set-order (order-of tl)} {:token-set-order (order-of ol)}
+ {:kind :token-set-order})
+ ;; themes, excluding hidden (mergeable: :token-theme)
+ themes (three-way-entities (lib-themes bl) (lib-themes tl) (lib-themes ol)
+ {:kind :token-theme})
+ ;; active theme paths (mergeable: :token-active-themes)
+ active-paths (three-way-entities {:active-themes (lib-active-paths bl)}
+ {:active-themes (lib-active-paths tl)}
+ {:active-themes (lib-active-paths ol)}
+ {:kind :token-active-themes})
+ ;; active-set toggles (hidden theme) — NOT yet mergeable
+ active-sets (three-way-entities {:active-sets (lib-hidden-sets bl)}
+ {:active-sets (lib-hidden-sets tl)}
+ {:active-sets (lib-hidden-sets ol)}
+ {:kind :token-active-sets})
+ ;; per-token values (mergeable: :token). Sets deleted on either side
+ ;; relative to the base are decided wholesale at set level (delete or
+ ;; delete-conflict), so their per-token diff would only add redundant
+ ;; token-level noise — skip them.
+ set-ids (set/union bids tids oids)
+ deleted-on-a-side? (fn [sid]
+ (and (contains? bids sid)
+ (or (not (contains? tids sid))
+ (not (contains? oids sid)))))
+ tokens (map (fn [sid]
+ (three-way-entities (lib-tokens-by-id bl sid)
+ (lib-tokens-by-id tl sid)
+ (lib-tokens-by-id ol sid)
+ {:kind :token :set-id sid}))
+ (remove deleted-on-a-side? set-ids))]
+ (merge-results (concat [presence rename order themes active-paths active-sets] tokens))))
+
+(defn remap-refs
+ "Rewrite cross-file references in `data` through `id-map`
+ ({old-id -> new-id}): the file refs relinked by `duplicate-file`
+ (`:component-file`, `:fill-color-ref-file`, `:stroke-color-ref-file`,
+ `:typography-ref-file`, shadow/grid `:file-id`) plus the media refs
+ (shape `:metadata`/`:fill-image`/`:stroke-image` ids, the `:media`
+ collection keys and library-color `:image` ids) — the exact same
+ surface `binfile.common/process-file` remaps when the branch copy is
+ created.
+
+ A branch is a file copy whose LOCAL references were re-pointed to its
+ own ids by the duplication pipeline. Before diffing or merging against
+ another file those references must be normalized to the target's ids;
+ otherwise (a) every affected entity looks modified, inflating the
+ diff/conflicts, and (b) merged references cannot be resolved in the
+ target file — repair would detach components and images would break.
+ Ids not present in `id-map` (external libraries) are left untouched."
+ [data id-map]
+ (if (empty? id-map)
+ data
+ (let [lookup #(get id-map % %)]
+ (-> data
+ (d/update-when :pages-index #(cfh/relink-refs % lookup))
+ (d/update-when :components #(cfh/relink-refs % lookup))
+ (d/update-when :media
+ (fn [media]
+ (reduce-kv (fn [res k v]
+ (let [id (lookup k)]
+ (if (= id k)
+ res
+ (-> res (dissoc k) (assoc id (assoc v :id id))))))
+ media
+ media)))
+ (d/update-when :colors
+ (fn [colors]
+ (reduce-kv (fn [res k v]
+ (let [image-id (get-in v [:image :id])
+ new-id (some-> image-id lookup)]
+ (if (or (nil? image-id) (= new-id image-id))
+ res
+ (assoc-in res [k :image :id] new-id))))
+ colors
+ colors)))))))
+
+(defn- strip-modified-at
+ "Remove the `:modified-at` bookkeeping timestamp from every entry of an
+ indexed collection (components, colors, typographies).
+
+ `:modified-at` is a \"last touched\" stamp refreshed on EVERY apply
+ (`components-list/touch`, `types.library/touch`): diffing it flags
+ spurious `:modified` entries — e.g. an update-from-main restamps the
+ copied entities, making them differ from main forever after — and, when
+ both sides edited the same entity, spurious `:modify-modify` conflicts
+ whose only difference is the timestamp. Dropping it is safe because the
+ change pipeline regenerates it on apply anyway."
+ [index]
+ (persistent!
+ (reduce-kv (fn [acc id v]
+ (assoc! acc id (dissoc v :modified-at)))
+ (transient {})
+ (or index {}))))
+
+(defn compute-merge
+ "Compute the three-way diff between the merge `base`, `main` and
+ `branch` file `:data`. Returns:
+
+ {:changes [ ...] ; clean, branch -> main
+ :conflicts [ ...] ; need resolution
+ :stats {:added n :modified n :deleted n :conflicts n}}
+
+ NOTE: token-lib diffing is not yet implemented (handled in a later
+ phase); `:tokens-lib` changes are not reported here."
+ [base main branch dir]
+ (let [[theirs ours] (if (= dir :main->branch) [branch main] [main branch])
+ results [(three-way-entities (strip-modified-at (:colors base))
+ (strip-modified-at (:colors theirs))
+ (strip-modified-at (:colors ours))
+ {:kind :color})
+ (three-way-entities (strip-modified-at (:typographies base))
+ (strip-modified-at (:typographies theirs))
+ (strip-modified-at (:typographies ours))
+ {:kind :typography})
+ (three-way-entities (strip-modified-at (:components base))
+ (strip-modified-at (:components theirs))
+ (strip-modified-at (:components ours))
+ {:kind :component})
+ (three-way-entities (:media base) (:media theirs) (:media ours)
+ {:kind :media})
+ (diff-pages base theirs ours)
+ (diff-tokens base theirs ours)]
+ {:keys [changes conflicts]} (merge-results results)]
+ {:changes changes
+ :conflicts conflicts
+ :stats {:added (count (filterv #(= :added (:status %)) changes))
+ :modified (count (filterv #(= :modified (:status %)) changes))
+ :deleted (count (filterv #(= :deleted (:status %)) changes))
+ :conflicts (count conflicts)}}))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; MERGE -> CHANGES (Phase 3, no-conflict path)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+;; Kinds `compute-changes` can translate into change ops. Components,
+;; pages (add/remove/rename) and tokens are not yet supported and cause
+;; the merge to refuse rather than silently drop changes.
+(def ^:private mergeable-kinds
+ #{:color :typography :media :shape :token :token-set :token-set-rename :token-set-order
+ :token-theme :token-active-themes :token-active-sets
+ :page :page-order :page-guide :page-flow :page-grid :page-plugin :component})
+
+(defn unsupported-kinds
+ "Set of change kinds present in `changes` that `compute-changes`
+ cannot translate yet."
+ [changes]
+ (into #{} (comp (map :kind) (remove mergeable-kinds)) changes))
+
+(defn- flat-changes
+ "Emit add/mod/del change maps for one flat id->value collection.
+
+ Clean (non-conflicting) changes are always emitted. Conflicting
+ entities are emitted only when `resolutions` selects `:branch` for that
+ id (taking the branch side); `:main` (or absent) leaves main untouched.
+ The caller is responsible for refusing the merge while conflicts remain
+ unresolved."
+ [base theirs ours resolutions add-fn mod-fn del-fn]
+ (reduce
+ (fn [acc id]
+ (let [b (get base id) t (get theirs id) o (get ours id)
+ in-b? (contains? base id)
+ in-t? (contains? theirs id)
+ in-o? (contains? ours id)
+ res (get resolutions id)]
+ (cond
+ ;; present in all three
+ (and in-b? in-o? in-t?)
+ (cond (= o b) acc ; branch didn't touch
+ (= t b) (conj acc (mod-fn id o)) ; main didn't touch, branch did
+ (= o t) acc ; both reached same value
+ (= res :branch) (conj acc (mod-fn id o)) ; modify/modify -> branch
+ (map? res) (let [eff (merge-attrs t o res)] ; modify/modify -> per-attr
+ (if (= eff t) acc (conj acc (mod-fn id eff))))
+ :else acc)
+
+ ;; deleted in branch, still in main
+ (and in-b? (not in-o?) in-t?)
+ (cond (= b t) (conj acc (del-fn id)) ; clean delete
+ (= res :branch) (conj acc (del-fn id)) ; delete/modify -> branch (delete)
+ :else acc)
+
+ ;; deleted in main, still in branch
+ (and in-b? in-o? (not in-t?))
+ (cond (= b o) acc ; clean (already gone in main)
+ (= res :branch) (conj acc (add-fn id o)) ; modify/delete -> keep branch
+ :else acc)
+
+ ;; new in branch only
+ (and (not in-b?) in-o? (not in-t?))
+ (conj acc (add-fn id o))
+
+ ;; added on both sides with different values (add/add)
+ (and (not in-b?) in-o? in-t? (not= o t))
+ (cond (= res :branch) (conj acc (mod-fn id o))
+ (map? res) (let [eff (merge-attrs t o res)]
+ (if (= eff t) acc (conj acc (mod-fn id eff))))
+ :else acc)
+
+ :else acc)))
+ []
+ (set/union (set (keys base)) (set (keys theirs)) (set (keys ours)))))
+
+(defn- index-of
+ [coll x]
+ (first (keep-indexed (fn [i v] (when (= v x) i)) coll)))
+
+(def ^:private structural-set-attrs
+ "Attrs NOT expressible as a plain `:set` op: containment/order are applied
+ via `:mov-objects` and add/del object changes, so setting their raw values
+ would corrupt the tree (leave shapes loose / duplicated)."
+ #{:shapes :parent-id :frame-id})
+
+(def ^:private shape-geometry-attrs
+ "Attrs whose change invalidates the geometry caches (`:selrect`/`:points`),
+ which must then follow the winning side (see `shape-resolved-ops`)."
+ #{:x :y :width :height :rotation :transform :transform-inverse :flip-x :flip-y})
+
+(defn- shape-set-ops
+ "`:set` operations for the attrs that differ between the main and branch
+ shape (FULL maps, so geometry caches ride along), excluding the
+ structural ones (see `structural-set-attrs`)."
+ [t o]
+ (->> (shallow-attr-diff t o)
+ (into [] (comp (remove (fn [[k _]] (contains? structural-set-attrs k)))
+ (map (fn [[k {:keys [branch]}]] {:type :set :attr k :val branch}))))))
+
+(defn- shape-resolved-ops
+ "`:set` ops for a per-attr conflict resolution: the classification attrs
+ (stripped maps) the user resolved to `:branch`, valued from the FULL
+ branch shape; when a geometry attr is taken, the geometry caches
+ (`:selrect`/`:points`) are appended from the branch shape so position and
+ caches stay consistent."
+ [t-stripped o-stripped t-full o-full res]
+ (let [chosen (into []
+ (keep (fn [[k _]]
+ (when (and (= :branch (attr-side res k))
+ (not (contains? structural-set-attrs k)))
+ k)))
+ (shallow-attr-diff t-stripped o-stripped))
+ ops (mapv (fn [k] {:type :set :attr k :val (get o-full k)}) chosen)]
+ (if (some shape-geometry-attrs chosen)
+ (into ops
+ (keep (fn [k]
+ (when (not= (get t-full k) (get o-full k))
+ {:type :set :attr k :val (get o-full k)})))
+ [:selrect :points])
+ ops)))
+
+(defn- subtree-ids
+ "`id` plus all its descendants' ids, walking `:shapes` in `objects`."
+ [objects id]
+ (loop [pending [id] out []]
+ (if (empty? pending)
+ out
+ (let [cur (peek pending)]
+ (recur (into (pop pending) (get-in objects [cur :shapes]))
+ (conj out cur))))))
+
+(defn- shape-depth
+ "Depth of a shape in its objects tree (root = 0). Used to order moves so a
+ container is relocated before the shapes the branch moved into it."
+ [objects id]
+ (loop [id id d 0]
+ (let [p (:parent-id (get objects id))]
+ (if (and p (not= p id) (contains? objects p))
+ (recur p (inc d))
+ d))))
+
+(defn- page-move-changes
+ "Emit `:mov-objects` for EXISTING shapes the branch reparented to a
+ different container, when the branch wins (main left the shape untouched,
+ or the conflict was resolved to `:branch`). Reparenting cannot be applied
+ as a `:set :parent-id` op — that only rewrites the attribute and leaves
+ the shape loose in its old parent, duplicated by file repair.
+ `sbo`/`sto` are the STRIPPED base/main objects (see `strip-shapes`), used
+ for the \"main untouched\" check so derived-attr churn on main does not
+ block a branch reparent."
+ [to oo sbo sto resolutions page-id]
+ (->> (keys oo)
+ (keep (fn [id]
+ (let [t (get to id)
+ o (get oo id)]
+ (when (and (some? t) ; exists on both sides
+ (not= id uuid/zero)
+ (or (= (get sbo id) (get sto id)) ; main untouched -> branch wins
+ (= :branch (attr-side (get resolutions id) :parent-id))))
+ (let [new-parent (:parent-id o)]
+ (when (not= new-parent (:parent-id t)) ; reparented
+ {:id id
+ :parent-id new-parent
+ :index (index-of (get-in oo [new-parent :shapes]) id)
+ :depth (shape-depth oo id)}))))))
+ (sort-by :depth)
+ (mapv (fn [{:keys [id parent-id index]}]
+ {:type :mov-objects
+ :page-id page-id
+ :parent-id parent-id
+ :index index
+ :shapes [id]
+ :ignore-touched true}))))
+
+(defn- page-shape-changes
+ [base theirs ours resolutions page-id]
+ (let [bo (get-in base [:pages-index page-id :objects] {})
+ to (get-in theirs [:pages-index page-id :objects] {})
+ oo (get-in ours [:pages-index page-id :objects] {})
+
+ ;; classification runs on STRIPPED shapes (no derived attrs) so
+ ;; `:shapes`/`:touched`/cache churn cannot fabricate changes or
+ ;; conflicts; emission reads the FULL maps.
+ sbo (strip-shapes bo)
+ sto (strip-shapes to)
+ soo (strip-shapes oo)
+
+ ;; modifications + deletions (additions handled below, ordered)
+ mod-del
+ (->> (flat-changes sbo sto soo resolutions
+ (fn [_ _] nil)
+ (fn [id _]
+ (let [res (get resolutions id)
+ ops (if (and (map? res)
+ (not= (get sto id) (get sbo id))) ; genuine conflict -> per-attr
+ (shape-resolved-ops (get sto id) (get soo id)
+ (get to id) (get oo id) res)
+ (shape-set-ops (get to id) (get oo id)))]
+ (when (seq ops)
+ {:type :mod-obj :page-id page-id :id id :operations ops})))
+ (fn [id]
+ {:type :del-obj :page-id page-id :id id :ignore-touched true}))
+ (filterv some?))
+
+ ;; additions: present in branch, absent from base and main
+ added-set (into #{} (filter (fn [id]
+ (and (contains? oo id)
+ (not (contains? bo id))
+ (not (contains? to id)))))
+ (keys oo))
+
+ ;; modify-delete conflicts resolved to `:branch`: the branch modified
+ ;; a shape main deleted and the user chose to keep it — re-add its
+ ;; whole surviving subtree (main's delete was recursive), except the
+ ;; descendants main still has (it moved them out before deleting)
+ restore-set
+ (into #{}
+ (comp (filter (fn [id]
+ (and (contains? bo id)
+ (not (contains? to id))
+ (not= (get sbo id) (get soo id))
+ (= :branch (get resolutions id)))))
+ (mapcat #(subtree-ids oo %))
+ (remove #(contains? to %)))
+ (keys oo))
+
+ all-adds (set/union added-set restore-set)
+
+ ;; topological order so a newly-added parent is created before its
+ ;; newly-added children
+ ordered
+ (loop [pending (vec all-adds) done #{} out []]
+ (if (empty? pending)
+ out
+ (let [ready (filterv (fn [id]
+ (let [p (:parent-id (get oo id))]
+ (or (not (contains? all-adds p))
+ (contains? done p))))
+ pending)
+ ready (if (seq ready) ready (subvec pending 0 1))]
+ (recur (filterv (complement (set ready)) pending)
+ (into done ready)
+ (into out ready)))))
+
+ add-changes
+ (mapv (fn [id]
+ (let [o (get oo id)
+ parent (:parent-id o)
+ index (index-of (get-in oo [parent :shapes]) id)]
+ {:type :add-obj
+ :page-id page-id
+ :id id
+ ;; container shapes (frame/group/…) require `:shapes`; reset
+ ;; it to empty so the schema is valid and children get
+ ;; appended by their own add-obj (add-shape inserts at index)
+ :obj (cond-> o (contains? o :shapes) (assoc :shapes []))
+ :parent-id parent
+ :frame-id (:frame-id o)
+ :index index
+ :ignore-touched true}))
+ ordered)
+
+ ;; reparenting of EXISTING shapes — applied last, after new
+ ;; containers exist and attr/add changes settled
+ move-changes (page-move-changes to oo sbo sto resolutions page-id)]
+ (-> mod-del
+ (into add-changes)
+ (into move-changes))))
+
+(defn compute-changes
+ "Translate the branch→main merge into a vector of raw change maps
+ applicable to main via `app.common.files.changes/process-changes`.
+
+ Clean changes are always included. Conflicting entities are included
+ only for those `resolutions` selects `:branch` (the caller must ensure
+ no conflict remains unresolved before applying).
+
+ Returns `{:changes [..] :unsupported #{kinds..}}`. When `:unsupported`
+ is non-empty the caller must refuse the merge (translation for those
+ kinds — components, pages, tokens — is not implemented yet).
+
+ The 5-arity accepts the `compute-merge` summary the caller usually
+ already computed (for the conflict gate), so the full three-way diff is
+ not run a second time just to derive `:unsupported`."
+ ([base main branch]
+ (compute-changes base main branch {}))
+ ([base main branch resolutions]
+ (compute-changes base main branch resolutions nil))
+ ([base main branch resolutions merge-summary]
+ (let [merge-summary (or merge-summary (compute-merge base main branch :branch->main))
+ unsupported (unsupported-kinds (concat (:changes merge-summary) (:conflicts merge-summary)))
+
+ ;; colors/typographies classified without :modified-at (the apply
+ ;; regenerates it via `touch`, so the emitted values may omit it)
+ colors (flat-changes (strip-modified-at (:colors base))
+ (strip-modified-at (:colors main))
+ (strip-modified-at (:colors branch))
+ resolutions
+ (fn [_ o] {:type :add-color :color o})
+ (fn [_ o] {:type :mod-color :color o})
+ (fn [id] {:type :del-color :id id}))
+
+ typos (flat-changes (strip-modified-at (:typographies base))
+ (strip-modified-at (:typographies main))
+ (strip-modified-at (:typographies branch))
+ resolutions
+ (fn [_ o] {:type :add-typography :typography o})
+ (fn [_ o] {:type :mod-typography :typography o})
+ (fn [id] {:type :del-typography :id id}))
+
+ media (flat-changes (:media base) (:media main) (:media branch) resolutions
+ (fn [_ o] {:type :add-media :object o})
+ (fn [_ o] {:type :mod-media :object o})
+ (fn [id] {:type :del-media :id id}))
+
+ ;; pages: add (full page incl. objects) / delete + rename (mod-page)
+ bpi (:pages-index base) mpi (:pages-index main) opi (:pages-index branch)
+ bpids (set (keys bpi)) mpids (set (keys mpi)) opids (set (keys opi))
+ common-pages (set/intersection mpids opids)
+ tri-common-pages (set/intersection bpids mpids opids)
+
+ ;; presence over page CONTENT (mirrors `diff-pages`): a clean delete
+ ;; requires main untouched; a delete conflict resolved to `:branch`
+ ;; either deletes (branch deleted) or RESTORES the full branch page
+ ;; via the add-fn (main deleted, branch edited)
+ page-presence (->> (flat-changes (page-content-map bpi bpids)
+ (page-content-map mpi mpids)
+ (page-content-map opi opids)
+ resolutions
+ (fn [pid _] {:type :add-page :page (get opi pid)})
+ (fn [_ _] nil)
+ (fn [pid] {:type :del-page :id pid}))
+ (filterv some?))
+ pmeta (fn [pi] (into {} (map (fn [id] [id (page-meta (get pi id))])) tri-common-pages))
+ page-meta-changes (->> (flat-changes (pmeta bpi) (pmeta mpi) (pmeta opi) resolutions
+ (fn [_ _] nil)
+ (fn [pid m] (assoc m :type :mod-page :id pid))
+ (fn [_] nil))
+ (filterv some?))
+
+ ;; page guides / flows per common page
+ page-guides (into []
+ (mapcat (fn [pid]
+ (flat-changes (get-in base [:pages-index pid :guides] {})
+ (get-in main [:pages-index pid :guides] {})
+ (get-in branch [:pages-index pid :guides] {})
+ resolutions
+ (fn [gid g] {:type :set-guide :page-id pid :id gid :params g})
+ (fn [gid g] {:type :set-guide :page-id pid :id gid :params g})
+ (fn [gid] {:type :set-guide :page-id pid :id gid :params nil}))))
+ common-pages)
+ page-flows (into []
+ (mapcat (fn [pid]
+ (flat-changes (get-in base [:pages-index pid :flows] {})
+ (get-in main [:pages-index pid :flows] {})
+ (get-in branch [:pages-index pid :flows] {})
+ resolutions
+ (fn [fid f] {:type :set-flow :page-id pid :id fid :params f})
+ (fn [fid f] {:type :set-flow :page-id pid :id fid :params f})
+ (fn [fid] {:type :set-flow :page-id pid :id fid :params nil}))))
+ common-pages)
+
+ ;; page default-grids per common page
+ page-grids (into []
+ (mapcat (fn [pid]
+ (flat-changes (get-in base [:pages-index pid :default-grids] {})
+ (get-in main [:pages-index pid :default-grids] {})
+ (get-in branch [:pages-index pid :default-grids] {})
+ resolutions
+ (fn [gt p] {:type :set-default-grid :page-id pid :grid-type gt :params p})
+ (fn [gt p] {:type :set-default-grid :page-id pid :grid-type gt :params p})
+ (fn [gt] {:type :set-default-grid :page-id pid :grid-type gt :params nil}))))
+ common-pages)
+
+ ;; page-level plugin-data per common page (flattened to [ns key] -> value)
+ page-plugins (into []
+ (mapcat (fn [pid]
+ (flat-changes (flatten-plugin-data (get-in base [:pages-index pid :plugin-data] {}))
+ (flatten-plugin-data (get-in main [:pages-index pid :plugin-data] {}))
+ (flatten-plugin-data (get-in branch [:pages-index pid :plugin-data] {}))
+ resolutions
+ (fn [[ns k] v] {:type :set-plugin-data :object-type :page :object-id pid :namespace ns :key k :value v})
+ (fn [[ns k] v] {:type :set-plugin-data :object-type :page :object-id pid :namespace ns :key k :value v})
+ (fn [[ns k]] {:type :set-plugin-data :object-type :page :object-id pid :namespace ns :key k :value nil}))))
+ common-pages)
+
+ ;; page order: reorder common pages to branch's order via mov-page
+ page-order-changes
+ (let [order-of (fn [data] (filterv tri-common-pages (or (:pages data) [])))
+ bo (order-of base) mo (order-of main) oo (order-of branch)]
+ (if (and (not= oo bo)
+ (or (= mo bo) (= (get resolutions :page-order) :branch)))
+ (vec (map-indexed (fn [i pid] {:type :mov-page :id pid :index i}) oo))
+ []))
+
+ shapes (into [] (mapcat #(page-shape-changes base main branch resolutions %)) common-pages)
+
+ ;; components: row metadata (shapes handled by the shape/page passes).
+ ;; A branch soft-delete keeps the row with `:deleted true`; surface it
+ ;; as a proper del-component so the deletion propagates.
+ components (flat-changes (strip-modified-at (:components base))
+ (strip-modified-at (:components main))
+ (strip-modified-at (:components branch)) resolutions
+ (fn [_ c] (assoc c :type :add-component))
+ (fn [id c] (if (:deleted c)
+ {:type :del-component :id id}
+ (assoc c :type :mod-component)))
+ (fn [id] {:type :del-component :id id}))
+
+ bl (:tokens-lib base) ml (:tokens-lib main) ol (:tokens-lib branch)
+ set-ids (set/union (lib-set-ids bl) (lib-set-ids ml) (lib-set-ids ol))
+
+ ;; set add/delete, presence over set CONTENT (mirrors `diff-tokens`):
+ ;; a new set carries metadata only (tokens come from the per-token
+ ;; pass); a RESTORED set (delete conflict resolved to `:branch`)
+ ;; carries its tokens, since the per-token pass skips deleted sets
+ set-presence (->> (flat-changes (set-content-map bl)
+ (set-content-map ml)
+ (set-content-map ol)
+ resolutions
+ (fn [sid _] {:type :set-token-set :id sid :attrs (set-restore-attrs ol bl sid)})
+ (fn [_ _] nil)
+ (fn [sid] {:type :set-token-set :id sid :attrs nil}))
+ (filterv some?))
+
+ ;; set rename: take branch name/description, keep main's tokens
+ ;; (the per-token pass then layers branch's token edits). Emitted
+ ;; before token-vals.
+ common-sets (set/intersection (lib-set-ids bl) (lib-set-ids ml) (lib-set-ids ol))
+ set-rename-changes
+ (into []
+ (comp (filter (fn [sid]
+ (let [b (set-meta-of bl sid)
+ m (set-meta-of ml sid)
+ o (set-meta-of ol sid)]
+ (and (not= o b)
+ (or (= m b) (= (get resolutions sid) :branch))))))
+ (map (fn [sid] {:type :set-token-set :id sid :attrs (set-rename-attrs ml ol sid)})))
+ common-sets)
+
+ ;; active theme paths
+ active-changes
+ (let [bp (lib-active-paths bl) mp (lib-active-paths ml) op (lib-active-paths ol)]
+ (cond
+ (= op bp) []
+ (= mp bp) [{:type :set-active-token-themes :theme-paths op}]
+ (= op mp) []
+ (= (get resolutions :active-themes) :branch) [{:type :set-active-token-themes :theme-paths op}]
+ :else []))
+
+ ;; active sets (hidden theme): bring branch's hidden theme
+ active-sets-changes
+ (let [bs (lib-hidden-sets bl) ms (lib-hidden-sets ml) os (lib-hidden-sets ol)
+ take! (fn [] (if-let [h (hidden-theme-map ol)]
+ [{:type :set-token-theme :id ctob/hidden-theme-id :attrs h}]
+ []))]
+ (cond
+ (= os bs) []
+ (= ms bs) (take!)
+ (= os ms) []
+ (= (get resolutions :active-sets) :branch) (take!)
+ :else []))
+
+ ;; set order: reorder main's common sets to branch's order using
+ ;; "move nᵢ before nᵢ₊₁" right-to-left. Emitted after renames so set
+ ;; names are settled.
+ set-order-changes
+ (let [bo (set-order-by-id bl common-sets)
+ mo (set-order-by-id ml common-sets)
+ oo (set-order-by-id ol common-sets)]
+ (if (and (not= oo bo)
+ (or (= mo bo) (= (get resolutions :token-set-order) :branch)))
+ (let [names (mapv #(ctob/get-name (ctob/get-set ol %)) oo)]
+ (vec (for [i (range (- (count names) 2) -1 -1)]
+ {:type :move-token-set
+ :from-path (set-name->path (nth names i))
+ :to-path (set-name->path (nth names i))
+ :before-path (set-name->path (nth names (inc i)))
+ :before-group false})))
+ []))
+ ;; sets deleted on either side (relative to the base) are decided
+ ;; wholesale at set level (delete, or restore-with-tokens); skip
+ ;; their per-token pass (mirrors `diff-tokens`)
+ skip-set-ids (into #{} (filter (fn [sid]
+ (and (contains? (lib-set-ids bl) sid)
+ (or (not (contains? (lib-set-ids ml) sid))
+ (not (contains? (lib-set-ids ol) sid))))))
+ set-ids)
+
+ token-vals (into []
+ (comp (remove skip-set-ids)
+ (mapcat (fn [sid]
+ (flat-changes (lib-tokens-by-id bl sid)
+ (lib-tokens-by-id ml sid)
+ (lib-tokens-by-id ol sid)
+ resolutions
+ (fn [tid t] {:type :set-token :set-id sid :token-id tid :attrs t})
+ (fn [tid t] {:type :set-token :set-id sid :token-id tid :attrs t})
+ (fn [tid] {:type :set-token :set-id sid :token-id tid :attrs nil})))))
+ set-ids)
+
+ themes (flat-changes (lib-themes bl) (lib-themes ml) (lib-themes ol) resolutions
+ (fn [tid t] {:type :set-token-theme :id tid :attrs t})
+ (fn [tid t] {:type :set-token-theme :id tid :attrs t})
+ (fn [tid] {:type :set-token-theme :id tid :attrs nil}))]
+
+ {:unsupported unsupported
+ ;; components before shapes so del-component can store the main-instance
+ ;; objects (still on the page) before del-obj removes them
+ :changes (vec (concat page-presence components shapes page-meta-changes
+ page-guides page-flows page-grids page-plugins page-order-changes
+ colors typos media
+ set-presence set-rename-changes set-order-changes
+ token-vals themes
+ active-changes active-sets-changes))})))
diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc
index 378f1e6d08..1764c05113 100644
--- a/common/src/app/common/flags.cljc
+++ b/common/src/app/common/flags.cljc
@@ -197,7 +197,13 @@
:link-preview
;; Shows the "link later" option on the files export modal.
- :export-link-later})
+ :export-link-later
+
+ ;; Enables file branching: isolated file copies ("branches") that
+ ;; can be merged back to their source file, including pull requests
+ ;; (review requests over a pinned snapshot of a branch). Disabled by
+ ;; default.
+ :branching})
(def all-flags
(set/union email login varia))
diff --git a/common/test/common_tests/files_branch_merge_test.cljc b/common/test/common_tests/files_branch_merge_test.cljc
new file mode 100644
index 0000000000..5501031f16
--- /dev/null
+++ b/common/test/common_tests/files_branch_merge_test.cljc
@@ -0,0 +1,996 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns common-tests.files-branch-merge-test
+ (:require
+ [app.common.files.branch-merge :as bm]
+ [app.common.files.changes :as cfc]
+ [app.common.types.tokens-lib :as ctob]
+ [app.common.uuid :as uuid]
+ [clojure.test :as t]))
+
+(defn- mkdata
+ [objects & {:keys [colors]}]
+ {:pages-index {:p1 {:id :p1 :name "Page 1" :objects objects}}
+ :colors (or colors {})
+ :typographies {}
+ :components {}
+ :media {}})
+
+(defn- pages-data
+ [pages-index pages & {:keys [components]}]
+ {:pages-index pages-index
+ :pages pages
+ :colors {} :typographies {} :media {}
+ :components (or components {})})
+
+(t/deftest identity-no-changes
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
+ r (bm/compute-merge base base base :branch->main)]
+ (t/is (= [] (:changes r)))
+ (t/is (= [] (:conflicts r)))
+ (t/is (= {:added 0 :modified 0 :deleted 0 :conflicts 0} (:stats r)))))
+
+(t/deftest added-in-branch
+ (let [base (mkdata {:s1 {:id :s1 :name "A"}})
+ branch (mkdata {:s1 {:id :s1 :name "A"}
+ :s2 {:id :s2 :name "B"}})
+ r (bm/compute-merge base base branch :branch->main)
+ c (first (filter #(= :added (:status %)) (:changes r)))]
+ (t/is (= 1 (-> r :stats :added)))
+ (t/is (= 0 (-> r :stats :conflicts)))
+ (t/is (= :shape (:kind c)))
+ (t/is (= :s2 (:id c)))
+ (t/is (= :p1 (:page-id c)))
+ (t/is (= "B" (:label c)))))
+
+(t/deftest modified-in-branch
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})
+ r (bm/compute-merge base base branch :branch->main)
+ c (first (:changes r))]
+ (t/is (= 1 (-> r :stats :modified)))
+ (t/is (= :modified (:status c)))
+ (t/is (= {:main "red" :branch "blue"} (get-in c [:changed-attrs :fill])))))
+
+(t/deftest root-frame-and-shapes-churn-hidden
+ ;; adding a top-level shape mutates the root frame's `:shapes`; that churn
+ ;; (and the root frame itself) must NOT show up as a change — only the new shape.
+ (let [base (mkdata {uuid/zero {:id uuid/zero :name "Root Frame" :shapes [:s1]}
+ :s1 {:id :s1 :name "A"}})
+ branch (mkdata {uuid/zero {:id uuid/zero :name "Root Frame" :shapes [:s1 :s2]}
+ :s1 {:id :s1 :name "A"}
+ :s2 {:id :s2 :name "B"}})
+ r (bm/compute-merge base base branch :branch->main)]
+ (t/is (= #{:s2} (set (map :id (:changes r)))))
+ (t/is (= 1 (-> r :stats :added)))
+ (t/is (= 0 (-> r :stats :modified)))))
+
+(t/deftest shape-with-only-structural-change-hidden
+ ;; a shape whose ONLY diff is structural/derived (here children reorder)
+ ;; is not reported as modified in the summary.
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :shapes [:c1 :c2]}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :shapes [:c2 :c1]}})
+ r (bm/compute-merge base base branch :branch->main)]
+ (t/is (= [] (:changes r)))
+ (t/is (= 0 (-> r :stats :modified)))))
+
+(t/deftest derived-attrs-stripped-containment-kept-and-merged
+ ;; SUMMARY strips only derived/cache attrs (:shapes/:selrect/:points), so
+ ;; meaningful attrs incl. reparenting (:parent-id/:frame-id) stay visible.
+ ;; MERGE applies scalar attrs via :set, but containment via :mov-objects
+ ;; (never :set parent-id/frame-id — that would corrupt the tree).
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"
+ :parent-id :old :frame-id :old
+ :selrect {:x 0} :points [1 2] :shapes [:c1]}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"
+ :parent-id :new :frame-id :new
+ :selrect {:x 9} :points [3 4] :shapes [:c1 :c2]}})
+ r (bm/compute-merge base base branch :branch->main)
+ c (first (:changes r))
+ chg (bm/compute-changes base base branch)
+ ops (->> (:changes chg)
+ (filter #(= :mod-obj (:type %)))
+ first :operations (map :attr) set)
+ movs (filter #(= :mov-objects (:type %)) (:changes chg))]
+ (t/is (= 1 (-> r :stats :modified)))
+ ;; summary: meaningful attrs (incl. containment) survive; caches stripped
+ (t/is (= #{:fill :parent-id :frame-id} (set (keys (:changed-attrs c)))))
+ ;; merge: scalar attrs are :set; containment is NOT a :set op
+ (t/is (contains? ops :fill))
+ (t/is (not (contains? ops :parent-id)))
+ (t/is (not (contains? ops :frame-id)))
+ (t/is (not (contains? ops :shapes)))
+ ;; merge: reparenting is applied via mov-objects to the branch parent
+ (t/is (= 1 (count movs)))
+ (t/is (= :new (:parent-id (first movs))))
+ (t/is (= [:s1] (:shapes (first movs))))))
+
+(t/deftest move-existing-layer-into-board-is-shown
+ ;; reparenting an existing layer into a new board must surface as a change
+ ;; on that layer (the user's "I moved the text into the board" action).
+ (let [base (mkdata {:t1 {:id :t1 :name "Text" :type :text
+ :parent-id uuid/zero :frame-id uuid/zero}})
+ branch (mkdata {:t1 {:id :t1 :name "Text" :type :text
+ :parent-id :board :frame-id :board}
+ :board {:id :board :name "Board" :type :frame :shapes [:t1]}})
+ r (bm/compute-merge base base branch :branch->main)
+ moved (first (filter #(and (= :modified (:status %)) (= :t1 (:id %))) (:changes r)))]
+ (t/is (some? moved))
+ (t/is (contains? (:changed-attrs moved) :frame-id))))
+
+(t/deftest shape-entries-carry-type-metadata
+ ;; diff entries expose the shape type / component nature so the compare
+ ;; view can pick a type-accurate icon and label.
+ (let [base (mkdata {})
+ branch (mkdata {:t1 {:id :t1 :name "Title" :type :text}
+ :r1 {:id :r1 :name "Box" :type :rect}
+ :c1 {:id :c1 :name "Btn" :type :frame
+ :component-id (uuid/random) :main-instance true}})
+ r (bm/compute-merge base base branch :branch->main)
+ by-id (into {} (map (juxt :id identity)) (:changes r))]
+ (t/is (= :text (get-in by-id [:t1 :shape-type])))
+ (t/is (= :rect (get-in by-id [:r1 :shape-type])))
+ (t/is (true? (get-in by-id [:c1 :component?])))))
+
+(t/deftest deleted-in-branch
+ (let [base (mkdata {:s1 {:id :s1 :name "A"} :s2 {:id :s2 :name "B"}})
+ branch (mkdata {:s1 {:id :s1 :name "A"}})
+ r (bm/compute-merge base base branch :branch->main)
+ c (first (:changes r))]
+ (t/is (= 1 (-> r :stats :deleted)))
+ (t/is (= :deleted (:status c)))
+ (t/is (= :s2 (:id c)))))
+
+(t/deftest fast-forward-main-untouched
+ ;; main == base; branch changed -> all branch changes apply cleanly
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})
+ r (bm/compute-merge base base branch :branch->main)]
+ (t/is (= 0 (-> r :stats :conflicts)))
+ (t/is (= 1 (-> r :stats :modified)))))
+
+(t/deftest main-only-change-not-reported
+ ;; branch == base; main changed -> branch contributes nothing
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
+ main (mkdata {:s1 {:id :s1 :name "A" :fill "green"}})
+ r (bm/compute-merge base main base :branch->main)]
+ (t/is (= [] (:changes r)))
+ (t/is (= 0 (-> r :stats :conflicts)))))
+
+(t/deftest modify-modify-conflict
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
+ main (mkdata {:s1 {:id :s1 :name "A" :fill "green"}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})
+ r (bm/compute-merge base main branch :branch->main)
+ cf (first (:conflicts r))]
+ (t/is (= 1 (-> r :stats :conflicts)))
+ (t/is (= :modify-modify (:reason cf)))
+ (t/is (= "green" (get-in cf [:changed-attrs :fill :main])))
+ (t/is (= "blue" (get-in cf [:changed-attrs :fill :branch])))))
+
+(t/deftest delete-modify-conflict
+ ;; deleted in branch, modified in main
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
+ main (mkdata {:s1 {:id :s1 :name "A" :fill "green"}})
+ branch (mkdata {})
+ r (bm/compute-merge base main branch :branch->main)
+ cf (first (:conflicts r))]
+ (t/is (= 1 (-> r :stats :conflicts)))
+ (t/is (= :delete-modify (:reason cf)))))
+
+(t/deftest color-added-in-branch
+ (let [base (mkdata {} :colors {})
+ branch (mkdata {} :colors {:c1 {:id :c1 :name "Primary"}})
+ r (bm/compute-merge base base branch :branch->main)
+ c (first (filter #(= :color (:kind %)) (:changes r)))]
+ (t/is (= :added (:status c)))
+ (t/is (= "Primary" (:label c)))))
+
+;; --- component :modified-at noise (bookkeeping timestamp)
+
+(defn- comp-data
+ [components]
+ {:pages-index {:p1 {:id :p1 :name "Page 1" :objects {}}}
+ :colors {} :typographies {} :media {}
+ :components components})
+
+(t/deftest component-modified-at-only-change-is-ignored
+ ;; editing a component bumps its `:modified-at`; if that timestamp is the
+ ;; ONLY difference it must not surface as a change or conflict, and must not
+ ;; emit a spurious :mod-component.
+ (let [cid (uuid/random)
+ base (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t0"}})
+ branch (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t1"}})
+ r (bm/compute-merge base base branch :branch->main)
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)]
+ (t/is (= [] (:changes r)))
+ (t/is (= [] (:conflicts r)))
+ (t/is (= 0 (-> r :stats :modified)))
+ (t/is (empty? (filter #(= :mod-component (:type %)) changes)))
+ (t/is (empty? unsupported))))
+
+(t/deftest component-modified-at-no-spurious-conflict
+ ;; both sides edited the component (different timestamps) but nothing else;
+ ;; the differing :modified-at must NOT create a modify-modify conflict.
+ (let [cid (uuid/random)
+ base (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t0"}})
+ main (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t1"}})
+ branch (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t2"}})
+ r (bm/compute-merge base main branch :branch->main)]
+ (t/is (= [] (:conflicts r)))
+ (t/is (= 0 (-> r :stats :conflicts)))))
+
+(t/deftest component-real-change-excludes-modified-at
+ ;; a genuine metadata change (rename) is reported, but the timestamp churn
+ ;; is stripped from the property changes shown to the user.
+ (let [cid (uuid/random)
+ base (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t0"}})
+ branch (comp-data {cid {:id cid :name "Primary Button" :path "" :modified-at "t1"}})
+ r (bm/compute-merge base base branch :branch->main)
+ c (first (filter #(= :component (:kind %)) (:changes r)))]
+ (t/is (= :modified (:status c)))
+ (t/is (= {:main "Button" :branch "Primary Button"} (get-in c [:changed-attrs :name])))
+ (t/is (not (contains? (:changed-attrs c) :modified-at)))))
+
+;; --- compute-changes (merge -> change maps)
+
+(t/deftest compute-changes-colors
+ (let [base (mkdata {} :colors {:c1 {:id :c1 :name "A"}})
+ branch (mkdata {} :colors {:c1 {:id :c1 :name "A2"}
+ :c2 {:id :c2 :name "B"}})
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ by-type (group-by :type changes)]
+ (t/is (empty? unsupported))
+ (t/is (= :c2 (-> by-type :add-color first :color :id)))
+ (t/is (= "A2" (-> by-type :mod-color first :color :name)))))
+
+(t/deftest remap-refs-remaps-local-only
+ ;; only ids present in the map are re-pointed; external-library refs and
+ ;; shapes with no refs are left untouched. Covers component, library
+ ;; color/typography and media references plus the :media/:colors indexes.
+ (let [branch-id (uuid/next)
+ main-id (uuid/next)
+ ext-lib (uuid/next)
+ media-old (uuid/next)
+ media-new (uuid/next)
+ data (-> (mkdata {:s1 {:id :s1 :component-file branch-id}
+ :s2 {:id :s2 :component-file ext-lib}
+ :s3 {:id :s3}
+ :s4 {:id :s4 :fill-color-ref-file branch-id
+ :typography-ref-file branch-id}
+ :s5 {:id :s5 :type :image :metadata {:id media-old}
+ :fill-image {:id media-old}}})
+ (assoc :media {media-old {:id media-old :name "img"}})
+ (assoc :colors {:c1 {:id :c1 :image {:id media-old}}}))
+ data' (bm/remap-refs data {branch-id main-id
+ media-old media-new})
+ objs (get-in data' [:pages-index :p1 :objects])]
+ (t/is (= main-id (get-in objs [:s1 :component-file])))
+ (t/is (= ext-lib (get-in objs [:s2 :component-file])))
+ (t/is (not (contains? (get objs :s3) :component-file)))
+ (t/is (= main-id (get-in objs [:s4 :fill-color-ref-file])))
+ (t/is (= main-id (get-in objs [:s4 :typography-ref-file])))
+ (t/is (= media-new (get-in objs [:s5 :metadata :id])))
+ (t/is (= media-new (get-in objs [:s5 :fill-image :id])))
+ (t/is (= {media-new {:id media-new :name "img"}} (:media data')))
+ (t/is (= media-new (get-in data' [:colors :c1 :image :id])))))
+
+(t/deftest merge-component-keeps-head-and-remaps-file
+ ;; a component created on a branch must merge into main as a VALID head:
+ ;; its main instance keeps component-id/main-instance and its
+ ;; component-file is re-pointed from the branch id to main's id (else main
+ ;; can't resolve the component and repair detaches it).
+ (let [comp-id :c1
+ mi-id :mi
+ branch-id (uuid/next)
+ main-id (uuid/next)
+ base (mkdata {uuid/zero {:id uuid/zero :type :frame :shapes []}})
+ branch (-> (mkdata {uuid/zero {:id uuid/zero :type :frame :shapes [mi-id]}
+ mi-id {:id mi-id :type :frame :name "Comp" :shapes []
+ :parent-id uuid/zero :frame-id uuid/zero
+ :component-root true :main-instance true
+ :component-id comp-id :component-file branch-id}})
+ (assoc :components {comp-id {:id comp-id :name "Comp" :path ""
+ :main-instance-id mi-id :main-instance-page :p1}}))
+ ;; emulate the RPC: canonicalize the branch's local file id to main's
+ branch' (bm/remap-refs branch {branch-id main-id})
+ {:keys [changes unsupported]} (bm/compute-changes base base branch')
+ add-mi (first (filter #(and (= :add-obj (:type %)) (= mi-id (:id %))) changes))
+ add-comp (first (filter #(= :add-component (:type %)) changes))]
+ (t/is (empty? unsupported))
+ ;; registry row merged
+ (t/is (= comp-id (:id add-comp)))
+ (t/is (= mi-id (:main-instance-id add-comp)))
+ ;; the merged main instance is still a head, re-pointed to main's id
+ (t/is (= main-id (get-in add-mi [:obj :component-file])))
+ (t/is (= comp-id (get-in add-mi [:obj :component-id])))
+ (t/is (true? (get-in add-mi [:obj :main-instance])))
+ (t/is (true? (get-in add-mi [:obj :component-root])))))
+
+(t/deftest compute-changes-shape-mod-del
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}
+ :s2 {:id :s2 :name "B"}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})
+ {:keys [changes]} (bm/compute-changes base base branch)
+ by-type (group-by :type changes)]
+ (t/is (= {:type :set :attr :fill :val "blue"}
+ (-> by-type :mod-obj first :operations first)))
+ (t/is (= :p1 (-> by-type :mod-obj first :page-id)))
+ (t/is (= :s2 (-> by-type :del-obj first :id)))))
+
+(t/deftest compute-changes-shape-add-topological
+ (let [objs {:root {:id :root :name "Root" :shapes []}}
+ base (mkdata objs)
+ branch (mkdata {:root {:id :root :name "Root" :shapes [:f1]}
+ :f1 {:id :f1 :name "Frame" :parent-id :root :frame-id :root :shapes [:c1]}
+ :c1 {:id :c1 :name "Child" :parent-id :f1 :frame-id :f1}})
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ adds (filterv #(= :add-obj (:type %)) changes)]
+ (t/is (empty? unsupported))
+ ;; parent added before child
+ (t/is (= [:f1 :c1] (mapv :id adds)))
+ (t/is (= :root (:parent-id (first adds))))
+ (t/is (= 0 (:index (first adds))))
+ ;; container shapes keep `:shapes` (reset to []), not dropped — frames
+ ;; require the key and would otherwise fail schema validation on apply
+ (t/is (= [] (get-in (first adds) [:obj :shapes])))
+ (t/is (contains? (:obj (first adds)) :shapes))
+ ;; leaf shapes that never had `:shapes` don't get the key added
+ (t/is (not (contains? (:obj (second adds)) :shapes)))
+ ;; children membership is not re-set via :shapes ops
+ (t/is (empty? (filter #(and (= :mod-obj (:type %)) (= :root (:id %))) changes)))))
+
+(t/deftest compute-changes-reparent-existing-shape
+ ;; main: rect R loose at root. branch: R moved into a new group G.
+ ;; the merge must MOVE R into G (mov-objects), not :set its parent-id —
+ ;; otherwise R is left loose at root and duplicated by file repair.
+ (let [base (mkdata {uuid/zero {:id uuid/zero :type :frame :shapes [:r]}
+ :r {:id :r :name "Rect" :type :rect
+ :parent-id uuid/zero :frame-id uuid/zero}})
+ branch (mkdata {uuid/zero {:id uuid/zero :type :frame :shapes [:g]}
+ :g {:id :g :name "Group" :type :group :shapes [:r]
+ :parent-id uuid/zero :frame-id uuid/zero}
+ :r {:id :r :name "Rect" :type :rect
+ :parent-id :g :frame-id uuid/zero}})
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ adds (filterv #(= :add-obj (:type %)) changes)
+ movs (filterv #(= :mov-objects (:type %)) changes)
+ r-mod (first (filter #(and (= :mod-obj (:type %)) (= :r (:id %))) changes))]
+ (t/is (empty? unsupported))
+ ;; G is added empty
+ (t/is (= [:g] (mapv :id adds)))
+ (t/is (= [] (get-in (first adds) [:obj :shapes])))
+ ;; R is MOVED into G at the right index (not re-added)
+ (t/is (= 1 (count movs)))
+ (t/is (= :g (:parent-id (first movs))))
+ (t/is (= [:r] (:shapes (first movs))))
+ (t/is (= 0 (:index (first movs))))
+ ;; the reparent is NOT emitted as a plain :set op on R
+ (let [set-attrs (set (map :attr (:operations r-mod)))]
+ (t/is (not (contains? set-attrs :parent-id)))
+ (t/is (not (contains? set-attrs :frame-id))))))
+
+
+(t/deftest compute-changes-resolve-shape-conflict
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
+ main (mkdata {:s1 {:id :s1 :name "A" :fill "green"}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})]
+ ;; resolved to branch -> sets the branch value
+ (let [{:keys [changes]} (bm/compute-changes base main branch {:s1 :branch})
+ op (-> (group-by :type changes) :mod-obj first :operations first)]
+ (t/is (= {:type :set :attr :fill :val "blue"} op)))
+ ;; resolved to main -> nothing emitted
+ (let [{:keys [changes]} (bm/compute-changes base main branch {:s1 :main})]
+ (t/is (empty? (filterv #(= :mod-obj (:type %)) changes))))
+ ;; unresolved -> nothing emitted (caller refuses the merge)
+ (let [{:keys [changes]} (bm/compute-changes base main branch {})]
+ (t/is (empty? (filterv #(= :mod-obj (:type %)) changes))))))
+
+(t/deftest compute-changes-resolve-color-conflict
+ (let [base (mkdata {} :colors {:c1 {:id :c1 :name "A"}})
+ main (mkdata {} :colors {:c1 {:id :c1 :name "Main"}})
+ branch (mkdata {} :colors {:c1 {:id :c1 :name "Branch"}})
+ {:keys [changes]} (bm/compute-changes base main branch {:c1 :branch})]
+ (t/is (= "Branch" (-> (group-by :type changes) :mod-color first :color :name)))))
+
+;; --- per-property (per-attr) conflict resolution
+
+(t/deftest conflict-resolved?-predicate
+ (let [cf {:changed-attrs {:fill {:main "g" :branch "b"}
+ :rx {:main 0 :branch 8}}}]
+ ;; whole-entity keyword always resolves
+ (t/is (true? (bm/conflict-resolved? cf :main)))
+ (t/is (true? (bm/conflict-resolved? cf :branch)))
+ ;; a full per-attr map resolves
+ (t/is (true? (bm/conflict-resolved? cf {:fill :branch :rx :main})))
+ ;; a partial map does not
+ (t/is (false? (bm/conflict-resolved? cf {:fill :branch})))
+ ;; nil / empty map do not
+ (t/is (false? (bm/conflict-resolved? cf nil)))
+ (t/is (false? (bm/conflict-resolved? cf {})))
+ ;; a map for a conflict without changed-attrs does not resolve
+ (t/is (false? (bm/conflict-resolved? {} {:fill :branch})))))
+
+(t/deftest compute-changes-resolve-shape-per-attr
+ ;; both fill and rx conflict; keep branch fill, keep main rx -> only the
+ ;; fill set op is emitted (rx resolved to main is a no-op against main)
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red" :rx 0}})
+ main (mkdata {:s1 {:id :s1 :name "A" :fill "green" :rx 4}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue" :rx 8}})
+ {:keys [changes]} (bm/compute-changes base main branch {:s1 {:fill :branch :rx :main}})
+ ops (-> (group-by :type changes) :mod-obj first :operations)]
+ (t/is (= [{:type :set :attr :fill :val "blue"}] ops))))
+
+(t/deftest compute-changes-resolve-shape-per-attr-both-branch
+ ;; per-attr map selecting branch for every attr equals the whole `:branch`
+ ;; resolution
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red" :rx 0}})
+ main (mkdata {:s1 {:id :s1 :name "A" :fill "green" :rx 4}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue" :rx 8}})
+ per (-> (bm/compute-changes base main branch {:s1 {:fill :branch :rx :branch}})
+ :changes (->> (group-by :type)) :mod-obj first :operations set)
+ whole (-> (bm/compute-changes base main branch {:s1 :branch})
+ :changes (->> (group-by :type)) :mod-obj first :operations set)]
+ (t/is (= whole per))
+ (t/is (= #{{:type :set :attr :fill :val "blue"}
+ {:type :set :attr :rx :val 8}} per))))
+
+(t/deftest compute-changes-resolve-color-per-attr
+ ;; color conflict on two attrs; take branch name, keep main description ->
+ ;; merged color carries branch name + main description
+ (let [base (mkdata {} :colors {:c1 {:id :c1 :name "A" :opacity 1}})
+ main (mkdata {} :colors {:c1 {:id :c1 :name "Main" :opacity 1}})
+ branch (mkdata {} :colors {:c1 {:id :c1 :name "Brnc" :opacity 0.5}})
+ {:keys [changes]} (bm/compute-changes base main branch {:c1 {:name :branch :opacity :main}})
+ color (-> (group-by :type changes) :mod-color first :color)]
+ (t/is (= "Brnc" (:name color)))
+ (t/is (= 1 (:opacity color)))))
+
+;; --- tokens
+
+(defn- token-lib
+ [set-id token-id value]
+ (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set {:id set-id :name "core"}))
+ (ctob/add-token set-id (ctob/make-token {:id token-id
+ :name "color.primary"
+ :type :color
+ :value value}))))
+
+(defn- with-tokens
+ [lib]
+ (assoc (mkdata {}) :tokens-lib lib))
+
+(t/deftest compute-changes-token-value-roundtrip
+ (let [sid (uuid/next)
+ tid (uuid/next)
+ base-lib (token-lib sid tid "#ff0000")
+ branch-lib (ctob/update-token base-lib sid tid
+ (fn [t] (ctob/make-token (assoc (into {} t) :value "#0000ff"))))
+ base (with-tokens base-lib)
+ main (with-tokens base-lib)
+ branch (with-tokens branch-lib)
+
+ {:keys [changes unsupported]} (bm/compute-changes base main branch)
+ set-token-change (first (filter #(= :set-token (:type %)) changes))]
+
+ (t/is (empty? unsupported))
+ (t/is (= sid (:set-id set-token-change)))
+ (t/is (= tid (:token-id set-token-change)))
+ (t/is (= "#0000ff" (-> set-token-change :attrs :value)))
+
+ ;; round-trip: apply the change to main's tokens-lib and read the value back
+ (let [data' (cfc/process-changes {:tokens-lib base-lib} changes)
+ tok (ctob/get-token (:tokens-lib data') sid tid)]
+ (t/is (= "#0000ff" (:value tok))))))
+
+(t/deftest compute-changes-token-set-add
+ (let [sid (uuid/next)
+ tid (uuid/next)
+ sid2 (uuid/next)
+ tid2 (uuid/next)
+ base-lib (token-lib sid tid "#ff0000")
+ branch-lib (-> base-lib
+ (ctob/add-set (ctob/make-token-set {:id sid2 :name "extra"}))
+ (ctob/add-token sid2 (ctob/make-token {:id tid2
+ :name "color.secondary"
+ :type :color
+ :value "#00ff00"})))
+ {:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
+ (with-tokens base-lib)
+ (with-tokens branch-lib))
+ by-type (group-by :type changes)]
+ (t/is (empty? unsupported))
+ (t/is (some #(= sid2 (:id %)) (:set-token-set by-type)))
+ (t/is (some #(= tid2 (:token-id %)) (:set-token by-type)))
+ ;; round-trip: the new set and its token exist in main afterwards
+ (let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
+ (t/is (some? (ctob/get-set (:tokens-lib data') sid2)))
+ (t/is (some? (ctob/get-token (:tokens-lib data') sid2 tid2))))))
+
+(t/deftest compute-changes-token-set-delete
+ (let [sid (uuid/next)
+ tid (uuid/next)
+ sid2 (uuid/next)
+ tid2 (uuid/next)
+ base-lib (-> (token-lib sid tid "#ff0000")
+ (ctob/add-set (ctob/make-token-set {:id sid2 :name "extra"}))
+ (ctob/add-token sid2 (ctob/make-token {:id tid2 :name "color.x"
+ :type :color :value "#00ff00"})))
+ branch-lib (ctob/delete-set base-lib sid2)
+ {:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
+ (with-tokens base-lib)
+ (with-tokens branch-lib))
+ del (filter #(and (= :set-token-set (:type %)) (nil? (:attrs %))) changes)]
+ (t/is (empty? unsupported))
+ (t/is (= sid2 (:id (first del))))
+ (let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
+ (t/is (nil? (ctob/get-set (:tokens-lib data') sid2))))))
+
+(t/deftest compute-changes-token-theme-add
+ (let [sid (uuid/next)
+ tid (uuid/next)
+ thid (uuid/next)
+ base-lib (token-lib sid tid "#ff0000")
+ branch-lib (ctob/add-theme base-lib (ctob/make-token-theme {:id thid :name "Dark" :group ""}))
+ {:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
+ (with-tokens base-lib)
+ (with-tokens branch-lib))
+ thm (filter #(= :set-token-theme (:type %)) changes)]
+ (t/is (empty? unsupported))
+ (t/is (= thid (:id (first thm))))
+ (let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
+ (t/is (some? (ctob/get-theme (:tokens-lib data') thid))))))
+
+;; --- pages
+
+(t/deftest compute-changes-page-add
+ (let [p1 {:id :p1 :name "Page 1" :objects {}}
+ p2 {:id :p2 :name "Page 2" :objects {:s1 {:id :s1 :name "A"}}}
+ base (pages-data {:p1 p1} [:p1])
+ branch (pages-data {:p1 p1 :p2 p2} [:p1 :p2])
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ add (first (filter #(= :add-page (:type %)) changes))]
+ (t/is (empty? unsupported))
+ (t/is (= :p2 (-> add :page :id)))
+ (t/is (contains? (-> add :page :objects) :s1))))
+
+(t/deftest compute-changes-page-delete
+ (let [p1 {:id :p1 :name "Page 1" :objects {}}
+ p2 {:id :p2 :name "Page 2" :objects {}}
+ base (pages-data {:p1 p1 :p2 p2} [:p1 :p2])
+ branch (pages-data {:p1 p1} [:p1])
+ {:keys [changes]} (bm/compute-changes base base branch)
+ del (first (filter #(= :del-page (:type %)) changes))]
+ (t/is (= :p2 (:id del)))))
+
+(t/deftest compute-changes-page-rename
+ (let [base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1])
+ branch (assoc-in base [:pages-index :p1 :name] "Renamed")
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ mod (first (filter #(= :mod-page (:type %)) changes))]
+ (t/is (empty? unsupported))
+ (t/is (= :p1 (:id mod)))
+ (t/is (= "Renamed" (:name mod)))))
+
+(t/deftest compute-changes-page-order
+ (let [p1 (uuid/next) p2 (uuid/next) p3 (uuid/next)
+ pi {p1 {:id p1 :name "P1" :objects {}}
+ p2 {:id p2 :name "P2" :objects {}}
+ p3 {:id p3 :name "P3" :objects {}}}
+ base (pages-data pi [p1 p2 p3])
+ branch (pages-data pi [p3 p1 p2])
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)]
+ (t/is (empty? unsupported))
+ (t/is (seq (filter #(= :mov-page (:type %)) changes)))
+ ;; round-trip: main ends up in branch's page order
+ (let [data' (cfc/process-changes base changes)]
+ (t/is (= [p3 p1 p2] (:pages data'))))))
+
+(t/deftest compute-changes-page-guide
+ (let [pid (uuid/next)
+ gid (uuid/next)
+ guide {:id gid :axis :x :position 100}
+ base (pages-data {pid {:id pid :name "P" :objects {} :guides {}}} [pid])
+ branch (assoc-in base [:pages-index pid :guides gid] guide)
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ ch (first (filter #(= :set-guide (:type %)) changes))]
+ (t/is (empty? unsupported))
+ (t/is (= gid (:id ch)))
+ (t/is (= pid (:page-id ch)))
+ ;; round-trip: the guide is present on main afterwards
+ (let [data' (cfc/process-changes base changes)]
+ (t/is (contains? (get-in data' [:pages-index pid :guides]) gid)))))
+
+(t/deftest compute-changes-page-attrs-unsupported
+ ;; an unknown/unhandled residual page attr must be refused, never dropped
+ (let [base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1])
+ branch (assoc-in base [:pages-index :p1 :some-future-attr] {:x 1})
+ {:keys [unsupported]} (bm/compute-changes base base branch)]
+ (t/is (contains? unsupported :page-attrs))))
+
+(t/deftest compute-changes-page-default-grid
+ (let [pid (uuid/next)
+ grid {:size 16 :color {:color "#cccccc" :opacity 0.5}}
+ base (pages-data {pid {:id pid :name "P" :objects {} :default-grids {}}} [pid])
+ branch (assoc-in base [:pages-index pid :default-grids :square] grid)
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ ch (first (filter #(= :set-default-grid (:type %)) changes))]
+ (t/is (empty? unsupported))
+ (t/is (= pid (:page-id ch)))
+ (t/is (= :square (:grid-type ch)))
+ (t/is (= grid (:params ch)))
+ ;; round-trip: the grid is present on main afterwards
+ (let [data' (cfc/process-changes base changes)]
+ (t/is (= grid (get-in data' [:pages-index pid :default-grids :square]))))))
+
+(t/deftest compute-changes-page-default-grid-delete
+ (let [pid (uuid/next)
+ grid {:size 16 :color {:color "#cccccc" :opacity 0.5}}
+ base (pages-data {pid {:id pid :name "P" :objects {} :default-grids {:square grid}}} [pid])
+ branch (assoc-in base [:pages-index pid :default-grids] {})
+ {:keys [changes]} (bm/compute-changes base base branch)
+ ch (first (filter #(= :set-default-grid (:type %)) changes))]
+ (t/is (= :square (:grid-type ch)))
+ (t/is (nil? (:params ch)))
+ (let [data' (cfc/process-changes base changes)]
+ (t/is (not (contains? (get-in data' [:pages-index pid :default-grids]) :square))))))
+
+(t/deftest compute-changes-page-plugin-data
+ (let [pid (uuid/next)
+ base (pages-data {pid {:id pid :name "P" :objects {} :plugin-data {}}} [pid])
+ branch (assoc-in base [:pages-index pid :plugin-data :my-plugin] {"foo" "bar"})
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ ch (first (filter #(= :set-plugin-data (:type %)) changes))]
+ (t/is (empty? unsupported))
+ (t/is (= :page (:object-type ch)))
+ (t/is (= pid (:object-id ch)))
+ (t/is (= :my-plugin (:namespace ch)))
+ (t/is (= "foo" (:key ch)))
+ (t/is (= "bar" (:value ch)))
+ ;; round-trip
+ (let [data' (cfc/process-changes base changes)]
+ (t/is (= "bar" (get-in data' [:pages-index pid :plugin-data :my-plugin "foo"]))))))
+
+;; --- components
+
+(t/deftest compute-changes-component-add
+ (let [pid (uuid/next)
+ mi (uuid/next)
+ cid (uuid/next)
+ base (pages-data {pid {:id pid :name "Page 1" :objects {}}} [pid])
+ cmp {:id cid :name "Button" :path "" :main-instance-id mi :main-instance-page pid}
+ branch (assoc base :components {cid cmp})
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ add (first (filter #(= :add-component (:type %)) changes))]
+ (t/is (empty? unsupported))
+ (t/is (= cid (:id add)))
+ (t/is (= "Button" (:name add)))
+ ;; round-trip: the component row exists in main afterwards
+ (let [data' (cfc/process-changes {:components {}} changes)]
+ (t/is (contains? (:components data') cid)))))
+
+(t/deftest compute-changes-component-delete
+ (let [cmp {:id :c1 :name "Button" :path "" :main-instance-id :mi :main-instance-page :p1}
+ base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1] :components {:c1 cmp})
+ branch (assoc base :components {})
+ {:keys [changes]} (bm/compute-changes base base branch)
+ del (first (filter #(= :del-component (:type %)) changes))]
+ (t/is (= :c1 (:id del)))))
+
+(t/deftest compute-changes-component-soft-delete
+ (let [cmp {:id :c1 :name "Button" :path "" :main-instance-id :mi :main-instance-page :p1}
+ base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1] :components {:c1 cmp})
+ ;; soft-delete keeps the row but marks it deleted
+ branch (assoc-in base [:components :c1 :deleted] true)
+ {:keys [changes]} (bm/compute-changes base base branch)
+ del (first (filter #(= :del-component (:type %)) changes))]
+ ;; surfaced as a real delete, not a no-op mod
+ (t/is (= :c1 (:id del)))
+ (t/is (empty? (filter #(= :mod-component (:type %)) changes)))))
+
+(t/deftest compute-changes-component-variant-add
+ (let [cmp {:id :c1 :name "Button" :path "" :main-instance-id :mi :main-instance-page :p1
+ :variant-id :v1 :variant-properties [{:name "size" :value "lg"}]}
+ base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1])
+ branch (assoc base :components {:c1 cmp})
+ {:keys [changes unsupported]} (bm/compute-changes base base branch)
+ add (first (filter #(= :add-component (:type %)) changes))]
+ (t/is (empty? unsupported))
+ ;; variant metadata is carried into the add-component change
+ (t/is (= :v1 (:variant-id add)))
+ (t/is (= [{:name "size" :value "lg"}] (:variant-properties add)))))
+
+(t/deftest compute-changes-token-set-rename
+ (let [sid (uuid/next)
+ tid (uuid/next)
+ base-lib (token-lib sid tid "#ff0000")
+ ;; rename the set but keep its token
+ branch-lib (ctob/update-set base-lib sid
+ (fn [s] (ctob/make-token-set {:id (ctob/get-id s)
+ :name "renamed"
+ :tokens (ctob/get-tokens base-lib sid)})))
+ {:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
+ (with-tokens base-lib)
+ (with-tokens branch-lib))
+ ch (first (filter #(= :set-token-set (:type %)) changes))]
+ (t/is (empty? unsupported))
+ (t/is (= "renamed" (-> ch :attrs :name)))
+ ;; round-trip: set renamed AND its token preserved
+ (let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
+ (t/is (= "renamed" (ctob/get-name (ctob/get-set (:tokens-lib data') sid))))
+ (t/is (some? (ctob/get-token (:tokens-lib data') sid tid))))))
+
+(t/deftest compute-changes-token-set-order
+ (let [a (uuid/next) b (uuid/next) c (uuid/next)
+ base-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-set (ctob/make-token-set {:id a :name "a"}))
+ (ctob/add-set (ctob/make-token-set {:id b :name "b"}))
+ (ctob/add-set (ctob/make-token-set {:id c :name "c"})))
+ ;; branch reorders to c, a, b
+ branch-lib (ctob/move-set base-lib ["c"] ["c"] ["a"] false)
+ {:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
+ (with-tokens base-lib)
+ (with-tokens branch-lib))]
+ (t/is (empty? unsupported))
+ (t/is (seq (filter #(= :move-token-set (:type %)) changes)))
+ ;; round-trip: main ends up in branch's set order
+ (let [data' (cfc/process-changes {:tokens-lib base-lib} changes)
+ order (mapv ctob/get-name (ctob/get-sets (:tokens-lib data')))]
+ (t/is (= ["c" "a" "b"] order)))))
+
+(t/deftest compute-changes-token-active-sets
+ (let [sid (uuid/next)
+ tid (uuid/next)
+ base-lib (token-lib sid tid "#ff0000")
+ ;; branch toggles the "core" set active in the hidden theme
+ branch-lib (ctob/toggle-set-in-theme base-lib ctob/hidden-theme-id "core")
+ {:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
+ (with-tokens base-lib)
+ (with-tokens branch-lib))
+ ch (first (filter #(= :set-token-theme (:type %)) changes))
+ hidden-sets (fn [lib] (set (:sets (ctob/get-theme lib ctob/hidden-theme-id))))]
+ (t/is (empty? unsupported))
+ (t/is (= ctob/hidden-theme-id (:id ch)))
+ ;; round-trip: main's hidden theme active sets match the branch
+ (let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
+ (t/is (= (hidden-sets branch-lib) (hidden-sets (:tokens-lib data')))))))
+
+(t/deftest compute-changes-token-active-themes
+ (let [thid (uuid/next)
+ base-lib (-> (ctob/make-tokens-lib)
+ (ctob/add-theme (ctob/make-token-theme {:id thid :name "Dark" :group ""})))
+ branch-lib (ctob/activate-theme base-lib thid)
+ {:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
+ (with-tokens base-lib)
+ (with-tokens branch-lib))
+ ch (first (filter #(= :set-active-token-themes (:type %)) changes))]
+ (t/is (empty? unsupported))
+ (t/is (some? ch))
+ ;; round-trip: active theme paths match the branch
+ (let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
+ (t/is (= (set (ctob/get-active-theme-paths branch-lib))
+ (set (ctob/get-active-theme-paths (:tokens-lib data'))))))))
+
+;;; --- Regression tests: classification on stripped shapes ---
+
+(t/deftest no-false-conflict-when-both-add-children-to-same-frame
+ ;; both sides adding a child to the same frame only diverge on the
+ ;; frame's :shapes (derived) — that must NOT be a conflict on the frame.
+ (let [base (mkdata {:f1 {:id :f1 :name "Frame" :type :frame :shapes [:a]}
+ :a {:id :a :name "A"}})
+ main (mkdata {:f1 {:id :f1 :name "Frame" :type :frame :shapes [:a :m]}
+ :a {:id :a :name "A"}
+ :m {:id :m :name "M" :parent-id :f1 :frame-id :f1}})
+ branch (mkdata {:f1 {:id :f1 :name "Frame" :type :frame :shapes [:a :b]}
+ :a {:id :a :name "A"}
+ :b {:id :b :name "B" :parent-id :f1 :frame-id :f1}})
+ r (bm/compute-merge base main branch :branch->main)]
+ (t/is (= [] (:conflicts r)))
+ (t/is (= #{:b} (set (map :id (:changes r)))))))
+
+(t/deftest no-delete-conflict-on-touched-only-main-change
+ ;; branch deletes a shape; main only flipped its :touched (library sync,
+ ;; not a user change) — that must stay a CLEAN delete, not a conflict.
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :touched nil}})
+ main (mkdata {:s1 {:id :s1 :name "A" :touched #{:fill-group}}})
+ branch (mkdata {})
+ r (bm/compute-merge base main branch :branch->main)]
+ (t/is (= [] (:conflicts r)))
+ (t/is (= [:deleted] (mapv :status (:changes r))))))
+
+(t/deftest compute-changes-restores-shape-on-modify-delete-branch
+ ;; main deleted a frame the branch modified; resolving :branch must
+ ;; re-add the frame AND its surviving child (main's delete was recursive).
+ (let [base (mkdata {:f1 {:id :f1 :name "Frame" :type :frame :shapes [:c1]
+ :parent-id uuid/zero :frame-id uuid/zero}
+ :c1 {:id :c1 :name "Child" :parent-id :f1 :frame-id :f1}})
+ main (mkdata {})
+ branch (mkdata {:f1 {:id :f1 :name "Frame RENAMED" :type :frame :shapes [:c1]
+ :parent-id uuid/zero :frame-id uuid/zero}
+ :c1 {:id :c1 :name "Child" :parent-id :f1 :frame-id :f1}})
+ {:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
+ {:keys [changes]} (bm/compute-changes base main branch {:f1 :branch})
+ adds (into {} (comp (filter #(= :add-obj (:type %)))
+ (map (juxt :id identity)))
+ changes)]
+ (t/is (= [:modify-delete] (mapv :reason conflicts)))
+ ;; both the frame and its child come back, parent before child
+ (t/is (contains? adds :f1))
+ (t/is (contains? adds :c1))
+ (let [add-order (mapv :id (filter #(= :add-obj (:type %)) changes))
+ idx (fn [id] (first (keep-indexed (fn [i v] (when (= v id) i)) add-order)))]
+ (t/is (< (idx :f1) (idx :c1))))
+ (t/is (= "Frame RENAMED" (get-in adds [:f1 :obj :name])))))
+
+(t/deftest compute-changes-modify-delete-resolved-main-drops-shape
+ ;; same conflict resolved to :main -> nothing is re-added
+ (let [base (mkdata {:s1 {:id :s1 :name "A"}})
+ main (mkdata {})
+ branch (mkdata {:s1 {:id :s1 :name "A2"}})
+ {:keys [changes]} (bm/compute-changes base main branch {:s1 :main})]
+ (t/is (= [] (filterv #(= :add-obj (:type %)) changes)))))
+
+;;; --- Regression tests: order resolution ids ---
+
+(t/deftest page-order-conflict-id-and-resolution
+ ;; both sides reordered pages differently: the conflict id must be
+ ;; :page-order (not :order) and resolving it to :branch must apply
+ (let [p1 (uuid/next) p2 (uuid/next) p3 (uuid/next)
+ pi {p1 {:id p1 :name "P1" :objects {}}
+ p2 {:id p2 :name "P2" :objects {}}
+ p3 {:id p3 :name "P3" :objects {}}}
+ base (pages-data pi [p1 p2 p3])
+ main (pages-data pi [p2 p1 p3])
+ branch (pages-data pi [p3 p1 p2])
+ {:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
+ conflict (first (filter #(= :page-order (:kind %)) conflicts))
+ {:keys [changes]} (bm/compute-changes base main branch {:page-order :branch})]
+ (t/is (= :page-order (:id conflict)))
+ (t/is (seq (filter #(= :mov-page (:type %)) changes)))
+ (let [data' (cfc/process-changes (pages-data pi [p2 p1 p3]) changes)]
+ (t/is (= [p3 p1 p2] (:pages data'))))))
+
+;;; --- Regression tests: page delete-vs-modify ---
+
+(t/deftest page-deleted-in-branch-edited-in-main-conflicts
+ (let [p1 (uuid/next) p2 (uuid/next) s1 (uuid/next)
+ mk (fn [p2-page]
+ {:pages-index (cond-> {p1 {:id p1 :name "P1" :objects {}}}
+ p2-page (assoc p2 p2-page))
+ :pages (if p2-page [p1 p2] [p1])
+ :colors {} :typographies {} :media {} :components {}})
+ base (mk {:id p2 :name "P2" :objects {}})
+ main (mk {:id p2 :name "P2" :objects {s1 {:id s1 :name "New"}}})
+ branch (mk nil)
+ {:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
+ conflict (first (filter #(= :page (:kind %)) conflicts))]
+ (t/is (some? conflict))
+ (t/is (= :delete-modify (:reason conflict)))
+ (t/is (= p2 (:id conflict)))
+ ;; resolved to :branch -> the page is deleted
+ (let [{:keys [changes]} (bm/compute-changes base main branch {p2 :branch})]
+ (t/is (= [p2] (mapv :id (filter #(= :del-page (:type %)) changes)))))
+ ;; resolved to :main -> the page is kept, nothing emitted for it
+ (let [{:keys [changes]} (bm/compute-changes base main branch {p2 :main})]
+ (t/is (= [] (filterv #(= :del-page (:type %)) changes))))))
+
+(t/deftest page-deleted-in-main-edited-in-branch-restores
+ (let [p1 (uuid/next) p2 (uuid/next) s1 (uuid/next)
+ pi-with (fn [objects]
+ {p1 {:id p1 :name "P1" :objects {}}
+ p2 {:id p2 :name "P2" :objects objects}})
+ base (pages-data (pi-with {}) [p1 p2])
+ main (pages-data {p1 {:id p1 :name "P1" :objects {}}} [p1])
+ branch (pages-data (pi-with {s1 {:id s1 :name "New"}}) [p1 p2])
+ {:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
+ conflict (first (filter #(= :page (:kind %)) conflicts))]
+ (t/is (= :modify-delete (:reason conflict)))
+ ;; resolved to :branch -> the full branch page is re-added
+ (let [{:keys [changes]} (bm/compute-changes base main branch {p2 :branch})
+ add (first (filter #(= :add-page (:type %)) changes))]
+ (t/is (= p2 (get-in add [:page :id])))
+ (t/is (contains? (get-in add [:page :objects]) s1)))))
+
+(t/deftest page-clean-delete-still-clean
+ ;; branch deletes a page main did not touch -> clean delete, no conflict
+ (let [p1 (uuid/next) p2 (uuid/next)
+ pi {p1 {:id p1 :name "P1" :objects {}}
+ p2 {:id p2 :name "P2" :objects {}}}
+ base (pages-data pi [p1 p2])
+ branch (pages-data {p1 {:id p1 :name "P1" :objects {}}} [p1])
+ {:keys [conflicts]} (bm/compute-merge base base branch :branch->main)
+ {:keys [changes]} (bm/compute-changes base base branch)]
+ (t/is (= [] conflicts))
+ (t/is (= [p2] (mapv :id (filter #(= :del-page (:type %)) changes))))))
+
+;;; --- Regression tests: token-set delete-vs-modify ---
+
+(t/deftest token-set-deleted-in-branch-edited-in-main-conflicts
+ (let [sid (uuid/next) tid (uuid/next)
+ base-lib (token-lib sid tid "#ff0000")
+ ;; main edits a token inside the set the branch deletes
+ main-lib (ctob/update-token base-lib sid tid
+ (fn [t] (ctob/make-token (assoc (into {} t) :value "#00ff00"))))
+ branch-lib (ctob/delete-set base-lib sid)
+ base (with-tokens base-lib)
+ main (with-tokens main-lib)
+ branch (with-tokens branch-lib)
+ {:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
+ conflict (first (filter #(= :token-set (:kind %)) conflicts))]
+ (t/is (some? conflict))
+ (t/is (= :delete-modify (:reason conflict)))
+ ;; no redundant token-level conflicts for the deleted set
+ (t/is (= [] (filterv #(= :token (:kind %)) conflicts)))
+ ;; resolved to :branch -> set deleted; to :main -> set (and token) kept
+ (let [{:keys [changes]} (bm/compute-changes base main branch {sid :branch})]
+ (t/is (some #(and (= :set-token-set (:type %)) (nil? (:attrs %))) changes)))
+ (let [{:keys [changes]} (bm/compute-changes base main branch {sid :main})]
+ (t/is (= [] (filterv #(= :set-token-set (:type %)) changes)))
+ (t/is (= [] (filterv #(= :set-token (:type %)) changes))))))
+
+(t/deftest token-set-deleted-in-main-edited-in-branch-restores
+ (let [sid (uuid/next) tid (uuid/next)
+ base-lib (token-lib sid tid "#ff0000")
+ main-lib (ctob/delete-set base-lib sid)
+ branch-lib (ctob/update-token base-lib sid tid
+ (fn [t] (ctob/make-token (assoc (into {} t) :value "#0000ff"))))
+ base (with-tokens base-lib)
+ main (with-tokens main-lib)
+ branch (with-tokens branch-lib)
+ {:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
+ conflict (first (filter #(= :token-set (:kind %)) conflicts))]
+ (t/is (= :modify-delete (:reason conflict)))
+ ;; resolved to :branch -> the set is re-created WITH its tokens
+ (let [{:keys [changes]} (bm/compute-changes base main branch {sid :branch})
+ restore (first (filter #(and (= :set-token-set (:type %)) (some? (:attrs %))) changes))
+ data' (cfc/process-changes (with-tokens main-lib) changes)]
+ (t/is (some? restore))
+ (t/is (seq (get-in restore [:attrs :tokens])))
+ (t/is (= "#0000ff" (:value (ctob/get-token (:tokens-lib data') sid tid)))))))
+
+(t/deftest per-attr-geometry-resolution-carries-caches
+ ;; a modify-modify conflict where :x is resolved to :branch must also
+ ;; bring the branch's :selrect/:points so geometry stays consistent
+ (let [base (mkdata {:s1 {:id :s1 :name "A" :x 0
+ :selrect {:x 0} :points [{:x 0}]}})
+ main (mkdata {:s1 {:id :s1 :name "MAIN" :x 0
+ :selrect {:x 0} :points [{:x 0}]}})
+ branch (mkdata {:s1 {:id :s1 :name "A" :x 100
+ :selrect {:x 100} :points [{:x 100}]}})
+ {:keys [changes]} (bm/compute-changes base main branch
+ {:s1 {:x :branch :name :main}})
+ ops (->> changes
+ (filter #(= :mod-obj (:type %)))
+ first :operations
+ (map (juxt :attr :val))
+ (into {}))]
+ (t/is (= 100 (get ops :x)))
+ (t/is (= {:x 100} (get ops :selrect)))
+ (t/is (not (contains? ops :name)))))
+
+(t/deftest shape-conflicts-carry-full-shapes
+ ;; classification runs on STRIPPED shapes, but the conflict payload must
+ ;; carry the FULL sides: the resolution UI renders real shape previews
+ ;; that need :selrect/:points (frame-clip-def asserts (rect? selrect))
+ (let [selrect {:x 0 :y 0 :width 10 :height 10 :x1 0 :y1 0 :x2 10 :y2 10}
+ mk (fn [name] {:id :s1 :name name :type :frame
+ :selrect selrect :points [{:x 0 :y 0}] :shapes []})
+ base (mkdata {:s1 (mk "A")})
+ main (mkdata {:s1 (mk "MAIN")})
+ branch (mkdata {:s1 (mk "BRANCH")})
+ {:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
+ c (first conflicts)]
+ (t/is (= :modify-modify (:reason c)))
+ ;; changed-attrs stay noise-free (no derived attrs)
+ (t/is (= #{:name} (set (keys (:changed-attrs c)))))
+ ;; but every side carries the full shape, caches included
+ (t/is (= selrect (get-in c [:base :selrect])))
+ (t/is (= selrect (get-in c [:main :selrect])))
+ (t/is (= selrect (get-in c [:branch :selrect])))
+ (t/is (= "MAIN" (get-in c [:main :name])))
+ (t/is (= "BRANCH" (get-in c [:branch :name])))))
diff --git a/docs/technical-guide/developer/branching.md b/docs/technical-guide/developer/branching.md
new file mode 100644
index 0000000000..f982683143
--- /dev/null
+++ b/docs/technical-guide/developer/branching.md
@@ -0,0 +1,597 @@
+---
+title: 3.12. Branching
+desc: Learn how Penpot file branching works — isolated file copies, a three-way entity-level merge engine, conflict resolution, the RPC API and the workspace UI. View Penpot's technical guide for self-hosting, configuration and developer insights.
+---
+
+# File branching
+
+Branching lets a user take an isolated, editable copy of a file (a **branch**),
+work on it without touching the original (**main**), and later bring the changes
+back through a **merge**. It is conceptually the same idea Git offers for source
+code, applied to design files: the workspace even reuses a small set of `git-*`
+icons (`git-branch`, `git-branch-plus`, `git-commit`, `git-merge`) to make the
+mental model obvious.
+
+The whole feature is gated behind the `:branching` product flag and was added in
+the `2.16` API generation (all the RPC methods carry `::doc/added "2.16"`).
+
+## Conceptual model
+
+Branching always reasons about **three** versions of the same file:
+
+ * **main** — the source file, the one everybody else keeps editing.
+ * **branch** — the isolated copy the user works on.
+ * **merge base** — a frozen snapshot of main taken at the exact moment the
+ branch was created.
+
+Comparing two versions is not enough to decide who is right when a value
+differs. With the third (the base) the engine can tell *who* changed *what*:
+
+ * if main still equals the base, then the **branch** is the one that changed it;
+ * if the branch still equals the base, then **main** is the one that changed it;
+ * if **both** changed it, to different values, that is a **conflict** and a
+ human must pick a side.
+
+This is the classic **three-way merge** algorithm, applied at the granularity of
+individual *entities* (a color, a shape, a typography, a token, …) rather than to
+the file blob as a whole.
+
+A second, subtler property makes the whole thing possible: a branch is a copy
+that **preserves the internal `:data` ids** of every entity. Because "color X" in
+the branch and "color X" in main share the same id, the engine can pair them up
+and diff them. Without stable ids an id-based merge would be impossible.
+
+## Where the code lives
+
+The feature spans the three subsystems. The core, shared logic lives in
+`common` so it can run identically in the JVM and in JavaScript test runners.
+
+```text
+common/src/app/common/files/branch_merge.cljc ; three-way diff + merge->changes engine
+backend/src/app/rpc/commands/files_branch.clj ; RPC commands (create/list/diff/merge/update)
+backend/src/app/migrations/sql/0155-add-file-branch-table.sql ; schema (file_branch table + is_branch)
+frontend/src/app/main/data/workspace/branches.cljs ; frontend state + RPC calls (ptk events)
+frontend/src/app/main/ui/workspace/sidebar/branches.cljs ; panels & dialogs
+frontend/src/app/main/ui/dashboard/branches_popover.cljs ; dashboard file-card popover
+```
+
+Tests:
+
+```text
+common/test/common_tests/files_branch_merge_test.cljc ; unit tests for the diff/merge engine
+backend/test/backend_tests/rpc_file_branch_test.clj ; integration tests for the RPC commands
+```
+
+The branching commands closely mirror the patterns in
+`app.rpc.commands.files-snapshot`, and reuse the production change pipeline
+(`app.common.files.changes/process-changes`), the file snapshot machinery
+(`app.features.file-snapshots`) and the file-duplication pipeline
+(`app.rpc.commands.management/duplicate-file`).
+
+## Data model
+
+The migration adds one column to `file` and one new table.
+
+```sql
+ALTER TABLE file
+ ADD COLUMN is_branch boolean NOT NULL DEFAULT false;
+
+CREATE TABLE file_branch (
+ id uuid PRIMARY KEY,
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+
+ branch_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
+ source_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
+
+ base_snapshot_id uuid NULL,
+
+ -- main's revn when the base was (re)positioned
+ base_revn bigint NOT NULL,
+ -- the BRANCH file's revn at the same moment (independent counter)
+ base_branch_revn bigint NOT NULL DEFAULT 0,
+
+ created_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
+
+ name text NOT NULL,
+ description text NULL,
+
+ status text NOT NULL DEFAULT 'open'
+ CHECK (status IN ('open', 'merged', 'archived')),
+
+ merged_at timestamptz NULL,
+ merged_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
+ deleted_at timestamptz NULL
+);
+```
+
+Key points:
+
+ * **`is_branch`** on `file` flags the branch copy so it is hidden from the
+ normal project/team file listings, the dashboard **search**, the team
+ **trash** (and its restore path), and project **duplication/binfile
+ export** — you don't want every branch mixed in with real files, nor
+ restorable zombies without their `file_branch` row. When the *source*
+ file is deleted, the `delete-object` cascade propagates the logical
+ deletion to its branches (they would otherwise be unreachable forever).
+ * **`branch_file_id`** points to the file row that *is* the branch copy;
+ **`source_file_id`** points to main. A `UNIQUE` index on `branch_file_id`
+ guarantees a file can be the branch of at most one source.
+ * **`base_snapshot_id`** references the merge-base snapshot. **`base_revn`**
+ records MAIN's revision number and **`base_branch_revn`** the BRANCH file's
+ one at the moment the base was (re)positioned — the two files carry
+ independent revn counters, so each side's cheap "did anything change?" gate
+ compares against its own counter (see *ahead/behind* below). The migration
+ is intentionally idempotent (`IF NOT EXISTS` everywhere) so instances that
+ applied it under its old development name pick up new columns on re-apply.
+ * **`status`** moves through `open` → `merged` (after a successful integration)
+ or `open` ↔ `archived` (a reversible "hide it" state). Logical deletion is
+ handled the standard Penpot way via `deleted_at` rather than a hard delete.
+
+## Lifecycle of a branch
+
+### Creating a branch
+
+The `::create-file-branch` command (`files_branch.clj`) refuses to branch a
+branch (`:cannot-branch-a-branch`) and runs everything inside a single
+transaction (with `SET CONSTRAINTS ALL DEFERRED`, because the new file and
+its branch row reference each other) **under the file's advisory lock**
+(`db/xact-lock!`, the same one update-file takes), so the base snapshot and
+the duplicated copy are guaranteed to capture the SAME state of main — a
+concurrent save between the two would otherwise make the base differ from
+the branch's initial content and manufacture phantom diffs later:
+
+ 1. **Materialize the merge base.** `fsnap/create!` takes a *system* snapshot of
+ main labelled `branch-base/`, with a `deleted-at` 3650 days in the
+ future. The far-future deletion date keeps the snapshot from being pruned by
+ the snapshot GC while the branch is open — the diff needs it for as long as
+ the branch lives. The pin is **released** (rescheduled with the team's
+ normal deletion delay) whenever the branch is merged, deleted or its base
+ is repositioned, so closed branches do not leak multi-year snapshots. If
+ the snapshot can ever not be resolved, merge/update/diff **refuse** with
+ `:base-snapshot-missing` — silently falling back to main would degrade the
+ three-way merge into "branch overwrites main" without conflicts.
+ 2. **Duplicate main into the branch file.** `mgmt/duplicate-file` is run with
+ `bfc/*state*` bound to `{:index {file-id branch-id}}` and
+ `:reset-shared-flag true`. The duplicate pipeline remaps the
+ file/media/library ids but **preserves the internal `:data` ids** — the
+ precondition for the id-based merge.
+ 3. **Mark the new file as a branch** (`is_branch = true`) so it stays out of the
+ listings.
+ 4. **Insert the `file_branch` row** with `status = 'open'`, the base snapshot id
+ and `base_revn = (:revn file)`.
+
+Before any of this it enforces edition permissions on main and checks two quotas:
+`::quotes/branches-per-file` and `::quotes/branches-per-team` (configurable via
+`PENPOT_QUOTES_BRANCHES_PER_FILE` / `PENPOT_QUOTES_BRANCHES_PER_TEAM`,
+registered in `app.config`).
+
+On the frontend, `create-branch` (in `branches.cljs`) first force-persists any
+pending local changes, then calls `:create-file-branch`, so the branch starts
+from a fully-saved state of main.
+
+### Working on a branch
+
+Once created, main and the branch are two live, independent files. When a branch
+file is opened, the frontend fetches its context via `:get-file-branch-info`
+(events `fetch-branches` / `fetch-branch-context`) and shows a
+`branch-context-banner*` at the top of the workspace indicating "you are on a
+branch", together with its *ahead* / *behind* counts and the merge / update
+actions.
+
+### Ahead / behind counts
+
+`::get-file-branches` lists a file's branches and annotates each with
+entity-level `ahead` / `behind` / `conflicts` counts — the same numbers the
+compare dialog shows.
+
+Computing these means running the full three-way diff, which is expensive, so
+there is a two-level gate (`branch-diff-counts`):
+
+ * Each file carries a monotonically increasing `revn`. The command first
+ computes cheap revn deltas: `ahead-revn = branch_revn - base_branch_revn`,
+ `behind-revn = source_revn - base_revn` (`revn-deltas`) — each side against
+ its OWN counter; mixing them breaks after an update-from-main repositions
+ the base.
+ * If **both deltas are zero**, the branch is in sync and the diff is skipped
+ entirely (`[0 0 0]`).
+ * Otherwise main's `:data` is realized **once** and shared across all branches
+ of the same source. `ahead` is the count of clean changes in the
+ `:branch->main` direction, `behind` the count in the reverse direction, and
+ `conflicts` is only non-zero when *both* sides diverged. Only **open**
+ branches are diffed (merged/archived ones report `[0 0 0]`), and a listing
+ never fails because of one corrupted branch: count errors degrade to zeros
+ with a warning (the merge itself still refuses loudly).
+
+## The three-way diff engine
+
+`app.common.files.branch-merge` is the heart of the feature. It is a pure,
+side-effect-free engine: it reads three `:data` blobs and returns a serializable
+summary. It never mutates anything.
+
+### `three-way-entities` — the per-entity truth table
+
+`three-way-entities` diffs one indexed collection (`id -> value`) across
+base / theirs / ours and returns `{:changes [...] :conflicts [...]}`. For each id
+it applies this decision table (`theirs` is main, `ours` is the branch, in the
+default `:branch->main` direction):
+
+| Situation | Outcome |
+| ------------------------------------------- | ---------------------------------------- |
+| deleted in both | nothing |
+| deleted in branch, main intact (`= base`) | clean `:deleted` |
+| deleted in main, branch intact (`= base`) | nothing (already gone in main) |
+| deleted in branch / modified in main | **conflict** `:delete-modify` |
+| modified in branch / deleted in main | **conflict** `:modify-delete` |
+| new in branch only | clean `:added` |
+| new in main only | nothing |
+| added on both, same value | nothing |
+| added on both, different value | **conflict** `:add-add` |
+| present in all three, branch `= base` | nothing (main wins) |
+| present in all three, main `= base` | clean `:modified` (branch wins) |
+| present in all three, branch `= main` | nothing (converged) |
+| present in all three, all three differ | **conflict** `:modify-modify` |
+
+So there are exactly **four conflict reasons**: `:delete-modify`,
+`:modify-delete`, `:add-add` and `:modify-modify`. Every conflict descriptor
+carries `:base`, `:main` and `:branch` values plus a `:changed-attrs` map
+(attribute → `{:main v :branch v}`) so the UI can render a side-by-side diff.
+
+Shapes are diffed **stripped** of their derived attrs (`strip-shapes`:
+`:shapes`, `:touched`, `:selrect`, `:points`) and the page root frame
+(`uuid/zero`) is ignored. Stripping happens at CLASSIFICATION level, not just
+display: this is what prevents false conflicts — both sides adding children to
+the same frame only differ on `:shapes`, and a library sync on main only flips
+`:touched`; neither is a user change, so neither may turn a clean branch edit
+into a modify-modify/delete conflict. Containment (`:parent-id`/`:frame-id`)
+is deliberately kept: reparenting is a real, user-meaningful change. The same
+principle strips the `:modified-at` bookkeeping stamp from colors,
+typographies and components (`strip-modified-at`): the apply pipeline
+re-`touch`es them, so after an update-from-main the copied entities would
+otherwise differ from main forever.
+
+Pages and token sets get a **content-valued presence pass** (`presence-only`):
+presence is diffed over the normalized content instead of a bare marker, so
+deleting a page or token set the other side edited raises a proper
+`:delete-modify`/`:modify-delete` conflict instead of silently dropping those
+edits; content-only modifications are filtered out (the granular passes own
+them) and the conflict payloads are slimmed for the wire.
+
+### `compute-merge` — across the whole file
+
+`compute-merge` runs `three-way-entities` over every mergeable collection in the
+file and concatenates the results:
+
+ * **colors**, **typographies**, **components** (row metadata), **media** —
+ straightforward indexed collections.
+ * **pages** (`diff-pages`) — page add/delete, page metadata
+ (name/background/grid), per-page guides, flows, default-grids, plugin-data,
+ and the shapes (`:objects`) of every common page. Residual page attributes
+ that no pass handles are surfaced under the `:page-attrs` kind so they are
+ *refused* rather than silently dropped.
+ * **tokens** (`diff-tokens`) — token-set add/delete, set rename, set order,
+ themes, active theme paths, active-set toggles, and per-token values.
+
+The result is `{:changes [...] :conflicts [...] :stats {...}}`, where `:stats`
+counts added / modified / deleted / conflicts. The direction argument swaps which
+side is "theirs" and which is "ours": `:branch->main` (merge / compare) vs
+`:main->branch` (update from main).
+
+### `remap-refs` — the local-reference fix-up
+
+A branch is a file copy with its own ids: `duplicate-file` re-points every
+**local** file reference (`:component-file`, `:fill-color-ref-file`,
+`:stroke-color-ref-file`, `:typography-ref-file`, shadow/grid `:file-id`) to
+the branch file id, and gives the branch's `file_media_object` rows FRESH ids
+(relinking shape `:metadata`/`:fill-image`/`:stroke-image`, the `:media` index
+and library-color `:image` refs to them). If diffed as-is, every affected
+entity would look modified, inflating the diff and conflicts; worse, after a
+merge the references could not be resolved in the target file — repair would
+silently *detach* components and images would break when the branch file is
+GC'd.
+
+`bm/remap-refs [data id-map]` rewrites exactly the same reference surface the
+duplication pipeline remapped, through an `{old-id -> new-id}` map; ids not in
+the map (external libraries) are untouched. The backend builds that map per
+operation: the branch file id plus the **media pairs** (`media-pairs`, rows
+matched across the two files by storage object + name + dimensions + mtype).
+Media *added* on the source side of a merge/update gets fresh ids
+pre-allocated before the diff, and its `file_media_object` rows are **copied
+into the target file** when the operation applies — so merged shapes always
+reference rows owned by the file that outlives the branch.
+
+## Merging a branch into main
+
+`::merge-file-branch` integrates the branch back into main. Only an **editor of
+main** may do it (same rule as Figma). The whole operation runs in a transaction
+guarded by `db/xact-lock!` on **both** file ids (in stable order, to avoid
+deadlocks) — without the branch lock, edits saved to the branch while the merge
+runs would be silently lost when the branch is marked merged and deleted.
+
+The flow:
+
+ 1. **Load** main, the branch (normalized), and the base (from the snapshot).
+ 2. **Optimistic concurrency check.** If the caller passed `expected-main-revn`
+ and it no longer matches main's `revn`, it raises `:file-modified` ("recompute
+ the diff and retry"). The frontend always sends it (taken from the diff's
+ `:meta :main-revn`), so resolutions computed against a stale diff can never
+ apply; on `:file-modified` it re-fetches the diff and asks the user to
+ review again.
+ 3. **Conflict gate.** `compute-merge` is run; any conflict not resolved in the
+ `resolutions` map aborts with `{:status :conflicts :conflicts [...]}`.
+ 4. **Unsupported gate.** `compute-changes` is run; if it reports any
+ `:unsupported` kinds it aborts with `{:status :unsupported :kinds [...]}` —
+ nothing is applied, so no change is ever silently lost.
+ 5. **No-op shortcut.** If there are no changes to apply (the branch already
+ matches main), the branch is closed without touching main.
+ 6. **Apply.** Otherwise it:
+ * takes a **safety snapshot** of pre-merge main (`pre-merge/`, with the
+ team's normal deletion delay) so the merge can be rolled back via the
+ version history;
+ * applies the change vector through `cpc/process-changes`, bumping `revn`;
+ * **validates** the merged file (`cfv/validate-file`) and, if it finds
+ problems, runs `cfr/repair-file` and applies the repair changes;
+ * copies the `file_media_object` rows of media added on the branch into
+ main, under the pre-allocated ids the changes reference;
+ * writes a `file_change` (xlog) row, persists the file
+ (`fupd/persist-file!`), marks the branch `merged`;
+ * publishes a `:file-merged` message on the msgbus topic of main so every
+ open client reloads the file in real time.
+
+Closing the branch (`finish-branch!`) always releases the pinned base
+snapshot and, **unless the caller passes `:keep-branch true`**, logically
+deletes the branch and its file in the SAME transaction (`delete-branch!`) —
+merged copies do not pile up, and there is no client-driven second call that
+could be lost mid-way. The `:file-deleted` msgbus message carries the
+`session-id`, so the merging client (which is navigating to main on its own)
+is not raced into the dashboard by its own deletion.
+
+### Resolutions
+
+Conflict resolutions are a simple map of **entity id → `:main` | `:branch`**.
+The id is usually a uuid, but structural conflicts use keyword ids
+(`:active-themes`, `:active-sets`, `:page-order`, `:token-set-order` — each
+distinct, so resolving one can never accidentally satisfy another). `:branch`
+takes the branch side, `:main` (or absent) leaves main untouched. Resolving a
+delete conflict to `:branch` really restores: a shape comes back with its whole
+surviving subtree, a page with its full contents, a token set with its tokens. The frontend builds this
+map through `set-conflict-resolution` (one entity) and `set-all-resolutions`
+(bulk), then passes it to the merge command.
+
+## From merge to change ops: `compute-changes`
+
+A diff summary is not directly applicable; it has to become the same kind of raw
+change maps the editor produces. `compute-changes` does that translation and
+returns `{:changes [...] :unsupported #{...}}`. The RPCs pass it the
+`compute-merge` summary they already computed for the conflict gate, so the
+full three-way diff is not run a second time just to derive `:unsupported`.
+
+### Mergeable vs. unsupported kinds
+
+`mergeable-kinds` is the source of truth for what can be translated:
+
+```clojure
+#{:color :typography :media :shape :token :token-set :token-set-rename :token-set-order
+ :token-theme :token-active-themes :token-active-sets
+ :page :page-order :page-guide :page-flow :page-grid :page-plugin :component}
+```
+
+`unsupported-kinds` returns any kind present in the diff that is *not* in this set
+(notably `:page-attrs`, the residual page attributes). When that set is non-empty
+the merge is refused — the engine prefers to refuse rather than drop a change it
+cannot faithfully reproduce.
+
+### Per-kind translation
+
+`flat-changes` is the shared helper that walks a flat `id -> value` collection and
+emits add / modify / delete ops, honouring resolutions (a conflicting entity is
+emitted only when its resolution is `:branch`). The concrete op types it produces:
+
+ * **colors / typographies / media** — `:add-color` / `:mod-color` /
+ `:del-color`, and the analogous `:*-typography` / `:*-media` ops.
+ * **shapes** (`page-shape-changes`) — the most involved pass:
+ * modifications become `:mod-obj` with `:set` operations for the attrs that
+ differ (structural attrs `:shapes` / `:parent-id` / `:frame-id` are
+ excluded — they are applied via moves, not plain sets); per-attr
+ resolutions that take a geometry attr also carry the branch's
+ `:selrect`/`:points` so position and caches stay consistent;
+ * deletions become `:del-obj`;
+ * additions become `:add-obj`, emitted in **topological order** so a newly
+ added parent is created before its newly added children; a
+ modify-delete conflict resolved to `:branch` re-adds the shape's whole
+ surviving subtree the same way;
+ * reparenting of existing shapes becomes `:mov-objects`, emitted **last**
+ (after new containers exist) and ordered by depth so a container moves
+ before the shapes moved into it. Reparenting cannot be a `:set :parent-id`
+ op — that would leave the shape loose in its old parent and let file repair
+ duplicate it.
+ * **pages** — `:add-page` (full page incl. objects) / `:del-page`, `:mod-page`
+ (name/background/grid), `:mov-page` (reorder), plus per-page `:set-guide`,
+ `:set-flow`, `:set-default-grid` and `:set-plugin-data`.
+ * **components** — `:add-component` / `:mod-component` / `:del-component` (a
+ branch soft-delete, `:deleted true`, is surfaced as a real `:del-component`).
+ Components are emitted *before* shapes so `:del-component` can capture the
+ main-instance objects before `:del-obj` removes them.
+ * **tokens** — `:set-token` (per-token values), `:set-token-set` (create/delete
+ and rename), `:move-token-set` (reorder), `:set-token-theme`,
+ `:set-active-token-themes`.
+
+## Updating a branch from main
+
+`::update-branch-from-main` is the **reverse** direction: it brings into the
+branch the changes main received since the merge base. It needs edition
+permissions on the *branch* file, locks the *branch* file id, and applies main's
+changes (plus any conflicts resolved to `:main`) into the branch.
+
+It works like a merge with `compute-merge`/`compute-changes` run with the sides
+swapped, the same `:conflicts` / `:unsupported` gates, and the same safety
+snapshot. The one extra step at the end is that it **repositions the merge base**:
+the base snapshot is moved forward to the current state of main (releasing the
+previous one), and both `base_revn`/`base_branch_revn` are refreshed. When the
+branch carried its own changes, `base_branch_revn` is stored one revn short on
+purpose: the new base equals MAIN, so the branch still differs from it even if
+its revn never moves again — the trick keeps the cheap gate open and the diff
+reports the real counts. UI resolutions arrive in main/branch terms and are
+inverted server-side (per-attr maps included) before translation.
+
+## RPC API summary
+
+All commands live in `app.rpc.commands.files-branch`, are gated by
+`check-branching-enabled!`, and were added in `2.16`.
+
+| Command | Kind | Purpose |
+| ----------------------------- | -------- | --------------------------------------------------- |
+| `::create-file-branch` | mutation | Snapshot main + duplicate it into a branch |
+| `::get-file-branches` | query | List branches with ahead/behind/conflicts counts |
+| `::get-branch-diff` | query | Read-only three-way diff (either direction) |
+| `::merge-file-branch` | mutation | Integrate a branch into main (resolutions, `expected-main-revn`, `keep-branch`) |
+| `::update-branch-from-main` | mutation | Pull main's changes into the branch |
+| `::get-file-branch-info` | query | Branch metadata when opening a branch file |
+| `::update-file-branch` | mutation | Rename / edit description |
+| `::archive-file-branch` | mutation | Archive / restore a branch |
+| `::delete-file-branch` | mutation | Logically delete a branch |
+
+## Frontend integration
+
+### State and events
+
+`app.main.data.workspace.branches` holds the state and wraps the RPC calls as
+`ptk` events. The relevant pieces:
+
+ * State keys: `workspace-branches` (the list + status), `workspace-branch-diff`
+ (the diff result + selected change + resolutions) and `workspace-branch-context`
+ (metadata when editing a branch).
+ * Events: `fetch-branches`, `create-branch`, `open-branch`, `fetch-branch-diff`,
+ `set-conflict-resolution`, `set-all-resolutions`, `merge-branch`,
+ `update-branch-from-main`, `fetch-branch-context`, plus `rename-branch`,
+ `archive-branch`, `delete-branch`. Each maps to the matching `rp/cmd!` call
+ (`:create-file-branch`, `:get-file-branches`, `:get-branch-diff`,
+ `:merge-file-branch`, `:update-branch-from-main`, …).
+ * `reload-file-window` / `show-merge-result` handle the post-merge UX (reacting
+ to the `:file-merged` msgbus notification and showing the result).
+
+### UI components
+
+`app.main.ui.workspace.sidebar.branches` provides the workspace UI:
+
+ * **`branches-toolbox*`** — the sidebar panel listing the current, open and
+ archived branches; **`branch-entry*`** is a single branch card with rename /
+ archive / delete actions.
+ * **`create-branch-dialog*`** — the create modal (name + description).
+ * **`branch-compare-dialog*`** — a read-only diff viewer, filterable by category
+ (pages / components / colors / tokens) and status (added / modified / deleted).
+ * **`branch-conflicts-dialog*`** — the conflict-resolution UI, showing
+ base / main / branch side by side and letting the user pick a side per
+ conflict.
+ * **`branch-context-banner*`** — the top-of-workspace banner shown while editing
+ a branch, with the ahead/behind counts and the merge / update actions.
+ * `confirm-merge!` / `confirm-update!` guard the irreversible operations with a
+ confirmation modal.
+
+`app.main.ui.dashboard.branches_popover` adds a popover to the dashboard file
+card so branches can be opened or created without entering the file.
+
+## Testing
+
+The pure engine is covered by `common-tests.files-branch-merge-test`, which
+exercises `three-way-entities` and `compute-merge` across the add / modify /
+delete / conflict scenarios and can be run in both the JVM and JavaScript
+runners. The RPC layer is covered end-to-end by
+`backend-tests.rpc-file-branch-test` (create / list / diff / merge). See the
+[Unit tests](/technical-guide/developer/common/#unit-tests) section for how to run
+them.
+
+## Pull requests
+
+On top of branching, **pull requests** add a review step before the merge:
+the author of a branch asks one or more team members (**reviewers**) to
+evaluate their changes. The feature is part of branching and is gated
+behind the same `:branching` product flag; it lives in:
+
+```text
+backend/src/app/migrations/sql/0156-add-file-pull-request-tables.sql
+backend/src/app/rpc/commands/files_pull_request.clj
+frontend/src/app/main/data/workspace/pull_requests.cljs
+frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs
+backend/test/backend_tests/rpc_file_pull_request_test.clj
+```
+
+### Model
+
+A pull request is **not** a file copy. The `file_pull_request` row points
+to its `file_branch` and to a **pinned review snapshot** of the branch
+file (label `pr-review/`, the same ~10-year pin/release mechanics
+as the merge-base snapshot). There is at most one open pull request per
+branch (partial unique index).
+
+Opening a review (`?pr-id=` on the workspace url) opens the **live
+branch file as a completely normal workspace file** — navigable and
+editable, so reviewers can try and validate anything (edits go to the
+branch, as always) — decorated with the review banner and actions. The
+pinned snapshot backs two things instead of the canvas: the cheap
+"outdated" gate (`branch_revn > review_revn`) and the prototype
+**viewer**: `::get-view-only-bundle` accepts an optional `pr-id` and
+serves the snapshot overlay read-only (permissions taken from the target
+file, edition stripped; share-links do not apply), reachable from the
+review banner's "Try interactions" action.
+`::get-pull-request-bundle` serves the same snapshot overlay as a
+workspace-shaped file bundle for API consumers; it refuses once the pull
+request is closed, so the pinned state stops resolving when the review
+ends (closing, merging or deleting the branch releases the pin).
+
+Because the sandbox is pinned, the author can keep editing the branch;
+publishing the new state to reviewers is an explicit action
+(`::update-pull-request-snapshot`) that repositions the snapshot.
+`branch_revn > review_revn` is the cheap "outdated" gate, mirroring the
+ahead/behind revn gates.
+
+### Reviews
+
+Reviewers are stored in `file_pull_request_review` (one row per profile,
+`pending` / `approved` / `changes-requested`). A verdict records the
+`review_revn` it was issued over, so it goes **stale** (not deleted) when
+the author publishes newer changes. The aggregate `review-state` is
+derived, never stored: current `changes-requested` beats current
+`approved`; stale verdicts only count as "in review". Approval is
+**informative**: merging keeps requiring edition permissions on main and
+goes through the exact same `::merge-file-branch` gates.
+
+Stored status is only `open` → `merged`/`closed` (with reopen). All the
+UI states (approved, changes requested, outdated, conflicts) are derived
+per request from the reviews and the existing diff-count helpers.
+
+Accepting a pull request is offered from the sandbox banner (and the
+sidebar entries) to any user with edition permissions on main, but it is
+just an entry point into the exact same branch merge dialog and
+`::merge-file-branch` flow — including conflict resolution and the
+safety snapshot. Note that merging always integrates the LIVE branch
+state: an outdated pull request merges the newest changes, not the
+pinned review snapshot.
+
+### Lifecycle hooks
+
+The branch lifecycle closes its pull request: merge marks it `merged`,
+branch delete/archive marks it `closed` (see
+`close-branch-pull-requests!` in `files_branch.clj`), and the
+`delete-object` cascade closes it when the branch file dies through
+other paths (the row itself survives as history; it is only logically
+deleted when MAIN dies). Reviewers are notified by email
+(`review-request` template) and through the dashboard notifications
+dropdown (`::get-profile-pending-reviews`: open pull requests where the
+profile has no current verdict); `:pull-request-*` msgbus messages on
+main's topic keep open clients fresh.
+
+## Current scope and limitations
+
+The feature was built in phases, and the engine refuses anything it cannot yet
+reproduce faithfully rather than risk losing work:
+
+ * The merge is **id-based** and assumes base, main and branch share the same file
+ data version; explicit data-version normalization before diffing is a later
+ refinement.
+ * Residual page attributes (`:page-attrs`) are surfaced but **refused**, so a
+ merge that touches them returns `{:status :unsupported}`.
+ * Comments / comment-thread positions are deliberately **not** migrated across a
+ merge (the same choice Figma makes).
+ * The merge base snapshot is kept alive for the whole life of the branch; the
+ far-future `deleted-at` is what protects it from the snapshot GC.
diff --git a/frontend/playwright/data/pull-requests/close-pull-request.json b/frontend/playwright/data/pull-requests/close-pull-request.json
new file mode 100644
index 0000000000..7765148e99
--- /dev/null
+++ b/frontend/playwright/data/pull-requests/close-pull-request.json
@@ -0,0 +1,4 @@
+{
+ "~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
+ "~:status": "closed"
+}
diff --git a/frontend/playwright/data/pull-requests/get-branch-diff-empty.json b/frontend/playwright/data/pull-requests/get-branch-diff-empty.json
new file mode 100644
index 0000000000..36e25c7ce5
--- /dev/null
+++ b/frontend/playwright/data/pull-requests/get-branch-diff-empty.json
@@ -0,0 +1,16 @@
+{
+ "~:changes": [],
+ "~:conflicts": [],
+ "~:stats": {
+ "~:added": 0,
+ "~:modified": 0,
+ "~:deleted": 0,
+ "~:conflicts": 0
+ },
+ "~:meta": {
+ "~:base-at": "~m1713536343369",
+ "~:main-at": "~m1713873823633",
+ "~:branch-at": "~m1713873823633",
+ "~:main-revn": 11
+ }
+}
diff --git a/frontend/playwright/data/pull-requests/get-file-branch-info-branch.json b/frontend/playwright/data/pull-requests/get-file-branch-info-branch.json
new file mode 100644
index 0000000000..8aa8aa017b
--- /dev/null
+++ b/frontend/playwright/data/pull-requests/get-file-branch-info-branch.json
@@ -0,0 +1,15 @@
+{
+ "~:id": "~uc7ce0794-0992-8105-8004-38f280443998",
+ "~:branch-file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
+ "~:source-file-id": "~uc7ce0794-0992-8105-8004-38f280443997",
+ "~:name": "checkout",
+ "~:description": null,
+ "~:status": "open",
+ "~:created-by": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
+ "~:created-at": "~m1713536343369",
+ "~:updated-at": "~m1713873823633",
+ "~:source-name": "New File 1",
+ "~:ahead": 1,
+ "~:behind": 0,
+ "~:conflicts": 0
+}
diff --git a/frontend/playwright/data/pull-requests/get-file-branch-info-none.json b/frontend/playwright/data/pull-requests/get-file-branch-info-none.json
new file mode 100644
index 0000000000..19765bd501
--- /dev/null
+++ b/frontend/playwright/data/pull-requests/get-file-branch-info-none.json
@@ -0,0 +1 @@
+null
diff --git a/frontend/playwright/data/pull-requests/get-file-branches-empty.json b/frontend/playwright/data/pull-requests/get-file-branches-empty.json
new file mode 100644
index 0000000000..fe51488c70
--- /dev/null
+++ b/frontend/playwright/data/pull-requests/get-file-branches-empty.json
@@ -0,0 +1 @@
+[]
diff --git a/frontend/playwright/data/pull-requests/get-file-pull-requests.json b/frontend/playwright/data/pull-requests/get-file-pull-requests.json
new file mode 100644
index 0000000000..d8c2fb6842
--- /dev/null
+++ b/frontend/playwright/data/pull-requests/get-file-pull-requests.json
@@ -0,0 +1,35 @@
+[
+ {
+ "~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
+ "~:file-branch-id": "~uc7ce0794-0992-8105-8004-38f280443998",
+ "~:source-file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
+ "~:target-file-id": "~uc7ce0794-0992-8105-8004-38f280443997",
+ "~:title": "Checkout redesign",
+ "~:description": "Please review the new checkout flow",
+ "~:created-by": "~uc7ce0794-0992-8105-8004-38e630f29a9c",
+ "~:created-at": "~m1713536343369",
+ "~:updated-at": "~m1713873823633",
+ "~:status": "open",
+ "~:review-revn": 11,
+ "~:review-updated-at": "~m1713873823633",
+ "~:closed-at": null,
+ "~:closed-by": null,
+ "~:branch-name": "checkout",
+ "~:target-name": "New File 1",
+ "~:reviews": [
+ {
+ "~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
+ "~:state": "pending",
+ "~:reviewed-revn": null,
+ "~:comment": null,
+ "~:updated-at": "~m1713873823633",
+ "~:stale": false
+ }
+ ],
+ "~:review-state": "pending",
+ "~:outdated": false,
+ "~:ahead": 1,
+ "~:behind": 0,
+ "~:conflicts": 0
+ }
+]
diff --git a/frontend/playwright/data/pull-requests/get-pull-request.json b/frontend/playwright/data/pull-requests/get-pull-request.json
new file mode 100644
index 0000000000..0c7ec6cfc1
--- /dev/null
+++ b/frontend/playwright/data/pull-requests/get-pull-request.json
@@ -0,0 +1,33 @@
+{
+ "~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
+ "~:file-branch-id": "~uc7ce0794-0992-8105-8004-38f280443998",
+ "~:source-file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
+ "~:target-file-id": "~uc7ce0794-0992-8105-8004-38f280443997",
+ "~:title": "Checkout redesign",
+ "~:description": "Please review the new checkout flow",
+ "~:created-by": "~uc7ce0794-0992-8105-8004-38e630f29a9c",
+ "~:created-at": "~m1713536343369",
+ "~:updated-at": "~m1713873823633",
+ "~:status": "open",
+ "~:review-revn": 11,
+ "~:review-updated-at": "~m1713873823633",
+ "~:closed-at": null,
+ "~:closed-by": null,
+ "~:branch-name": "checkout",
+ "~:target-name": "New File 1",
+ "~:reviews": [
+ {
+ "~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
+ "~:state": "pending",
+ "~:reviewed-revn": null,
+ "~:comment": null,
+ "~:updated-at": "~m1713873823633",
+ "~:stale": false
+ }
+ ],
+ "~:review-state": "pending",
+ "~:outdated": false,
+ "~:ahead": 1,
+ "~:behind": 0,
+ "~:conflicts": 0
+}
diff --git a/frontend/playwright/data/pull-requests/submit-pull-request-review.json b/frontend/playwright/data/pull-requests/submit-pull-request-review.json
new file mode 100644
index 0000000000..3701757007
--- /dev/null
+++ b/frontend/playwright/data/pull-requests/submit-pull-request-review.json
@@ -0,0 +1,6 @@
+{
+ "~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
+ "~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
+ "~:state": "approved",
+ "~:reviewed-revn": 11
+}
diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js
index dd5f66fb04..9531299d72 100644
--- a/frontend/playwright/ui/pages/WorkspacePage.js
+++ b/frontend/playwright/ui/pages/WorkspacePage.js
@@ -109,8 +109,12 @@ export class WorkspacePage extends BaseWebSocketPage {
async waitForIdle(options) {
await this.page.evaluate(
- (options) => new Promise(
- (resolve) => globalThis.requestIdleCallback(resolve, options)), options);
+ (options) =>
+ new Promise((resolve) =>
+ globalThis.requestIdleCallback(resolve, options),
+ ),
+ options,
+ );
}
};
@@ -218,19 +222,26 @@ export class WorkspacePage extends BaseWebSocketPage {
fileId = this.fileId ?? WorkspacePage.anyFileId,
pageId = this.pageId ?? WorkspacePage.anyPageId,
pageName = "Page 1",
+ // extra query params appended verbatim, e.g. "&pr-id=" for the
+ // pull request review sandbox
+ extraParams = "",
} = {}) {
// Helpers often call setup (and this) several times per test with the
// same file. Re-navigating would reload the document and wipe the
// in-memory file state (e.g. tokens created by previous steps), so
- // only navigate when the target file actually changes. Extra query
- // params the app adds itself (e.g. layout=tokens) are ignored, and
- // navigating away and back still reloads as before.
+ // only navigate when the target file (or the extra params) actually
+ // changes. Extra query params the app adds itself (e.g. layout=tokens)
+ // are ignored, and navigating away and back still reloads as before.
const currentParams = new URL(this.page.url()).searchParams;
+ const extraSearchParams = new URLSearchParams(
+ extraParams.replace(/^&/, ""),
+ );
const sameFile =
currentParams.get("screen") === "workspace" &&
currentParams.get("team-id") === WorkspacePage.anyTeamId &&
currentParams.get("file-id") === fileId &&
- currentParams.get("page-id") === pageId;
+ currentParams.get("page-id") === pageId &&
+ [...extraSearchParams].every(([k, v]) => currentParams.get(k) === v);
if (!sameFile) {
// Drop mocks from any previous document: page.goto reloads the app,
// so entries registered by the old document would otherwise resolve
@@ -238,7 +249,7 @@ export class WorkspacePage extends BaseWebSocketPage {
// no longer exists in the new document.
MockWebSocketHelper.clear();
await this.page.goto(
- `/?screen=workspace&team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
+ `/?screen=workspace&team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}${extraParams}`,
);
}
@@ -256,7 +267,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async #waitForWebSocketReadiness(pageName) {
// TODO: find a better event to settle whether the app is ready to receive notifications via ws
- await expect(this.pageName).toHaveText(pageName, { timeout: 30000 })
+ await expect(this.pageName).toHaveText(pageName, { timeout: 30000 });
}
async sendPresenceMessage(fixture) {
diff --git a/frontend/playwright/ui/specs/pull-requests.spec.js b/frontend/playwright/ui/specs/pull-requests.spec.js
new file mode 100644
index 0000000000..53c92dd9ef
--- /dev/null
+++ b/frontend/playwright/ui/specs/pull-requests.spec.js
@@ -0,0 +1,168 @@
+import { test, expect } from "@playwright/test";
+import { WorkspacePage } from "../pages/WorkspacePage";
+
+const PR_ID = "c7ce0794-0992-8105-8004-38f280443999";
+
+test.beforeEach(async ({ page }) => {
+ await WorkspacePage.init(page);
+ await WorkspacePage.mockConfigFlags(page, ["enable-branching"]);
+});
+
+const setupPullRequestSandbox = async (workspacePage) => {
+ await workspacePage.setupEmptyFile();
+ await workspacePage.mockRPCs({
+ "get-file-branch-info?file-id=*":
+ "pull-requests/get-file-branch-info-none.json",
+ "get-file-branches?file-id=*": "pull-requests/get-file-branches-empty.json",
+ "get-file-pull-requests?file-id=*":
+ "pull-requests/get-file-pull-requests.json",
+ "get-pull-request?id=*": "pull-requests/get-pull-request.json",
+ "get-branch-diff?branch-id=*": "pull-requests/get-branch-diff-empty.json",
+ "submit-pull-request-review":
+ "pull-requests/submit-pull-request-review.json",
+ });
+};
+
+test("opening a file with pr-id shows the review sandbox banner", async ({
+ page,
+}) => {
+ const workspacePage = new WorkspacePage(page);
+ await setupPullRequestSandbox(workspacePage);
+ await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
+
+ await expect(page.getByText("Reviewing pull request")).toBeVisible();
+ await expect(page.getByText("Checkout redesign").first()).toBeVisible();
+ await expect(page.getByText("checkout → New File 1")).toBeVisible();
+
+ // only the essential actions are visible buttons...
+ await expect(page.getByRole("button", { name: "Details" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Compare" })).toBeVisible();
+
+ // ...the rest live behind the kebab menu
+ const banner = page.getByText("Reviewing pull request").locator("../../..");
+ await banner.getByRole("button", { name: "Options" }).click();
+ await expect(page.getByText("Try interactions")).toBeVisible();
+ await expect(page.getByText("Merge to main")).toBeVisible();
+ await expect(page.getByText("Exit review")).toBeVisible();
+});
+
+test("the compare dialog shows the proposed changes", async ({ page }) => {
+ const workspacePage = new WorkspacePage(page);
+ await setupPullRequestSandbox(workspacePage);
+ await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
+
+ await page.getByRole("button", { name: "Compare" }).click();
+
+ // the branching compare dialog is reused to inspect the changes
+ await expect(page.getByText("Compare changes")).toBeVisible();
+});
+
+test("accepting the pull request opens the merge dialog", async ({ page }) => {
+ const workspacePage = new WorkspacePage(page);
+ await setupPullRequestSandbox(workspacePage);
+ await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
+
+ const banner = page.getByText("Reviewing pull request").locator("../../..");
+ await banner.getByRole("button", { name: "Options" }).click();
+ await page.getByText("Merge to main").click();
+
+ // the branching merge dialog handles the actual integration
+ await expect(page.getByText("Merge into main")).toBeVisible();
+ await expect(page.getByText("checkout").first()).toBeVisible();
+});
+
+test("the review behaves like a normal, editable file", async ({ page }) => {
+ const workspacePage = new WorkspacePage(page);
+ await setupPullRequestSandbox(workspacePage);
+ await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
+
+ await expect(page.getByText("Reviewing pull request")).toBeVisible();
+ // the file is the live branch, opened normally: the design toolbar is
+ // available so reviewers can try and validate anything...
+ await expect(workspacePage.rectShapeButton).toBeVisible();
+ // ...and none of the read-only / inspect affordances kick in
+ await expect(page.getByText("Inspecting code")).toBeHidden();
+ await expect(page.getByText("View only")).toBeHidden();
+});
+
+test("an assigned reviewer can submit a verdict from the banner", async ({
+ page,
+}) => {
+ const workspacePage = new WorkspacePage(page);
+ await setupPullRequestSandbox(workspacePage);
+ await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
+
+ // the logged-in profile is an assigned reviewer in the fixture
+ await page.getByRole("button", { name: "Review", exact: true }).click();
+
+ await expect(page.getByText("Submit review").first()).toBeVisible();
+ await page.getByText("Request changes", { exact: true }).click();
+ await page.getByRole("button", { name: "Submit review" }).click();
+
+ await expect(page.getByText("Review submitted")).toBeVisible();
+});
+
+test("closing the pull request asks for confirmation", async ({ page }) => {
+ const workspacePage = new WorkspacePage(page);
+ await setupPullRequestSandbox(workspacePage);
+ await workspacePage.mockRPC(
+ "close-pull-request",
+ "pull-requests/close-pull-request.json",
+ );
+ await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
+
+ await page.getByRole("button", { name: "Details" }).click();
+ await page.getByRole("button", { name: "Close pull request" }).click();
+
+ // nothing is closed yet: a confirmation dialog takes over
+ await expect(page.getByText("will disappear")).toBeVisible();
+
+ // confirming closes the pull request; being inside its sandbox, the
+ // user is taken back to the file (no pr-id in the url)
+ await page.getByRole("button", { name: "Close pull request" }).click();
+ await expect(page).not.toHaveURL(/pr-id/);
+});
+
+test("on a branch, requesting a review is the primary action", async ({
+ page,
+}) => {
+ const workspacePage = new WorkspacePage(page);
+ await setupPullRequestSandbox(workspacePage);
+ // the open file IS a branch without an open pull request
+ await workspacePage.mockRPCs({
+ "get-file-branch-info?file-id=*":
+ "pull-requests/get-file-branch-info-branch.json",
+ "get-file-pull-requests?file-id=*":
+ "pull-requests/get-file-branches-empty.json",
+ });
+ await workspacePage.goToWorkspace();
+
+ // merging moved behind the kebab menu; requesting a review is primary
+ await expect(
+ page.getByRole("button", { name: "Request review" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Merge to main" }),
+ ).toBeHidden();
+
+ const banner = page.getByText("On branch").locator("../../..");
+ await banner.getByRole("button", { name: "Options" }).click();
+ await page.getByText("Merge to main").click();
+ await expect(page.getByText("Merge into main")).toBeVisible();
+});
+
+test("exiting the review takes the user back to the file", async ({ page }) => {
+ const workspacePage = new WorkspacePage(page);
+ await setupPullRequestSandbox(workspacePage);
+ await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
+
+ await expect(page.getByText("Reviewing pull request")).toBeVisible();
+ const banner = page.getByText("Reviewing pull request").locator("../../..");
+ await banner.getByRole("button", { name: "Options" }).click();
+ await page.getByText("Exit review").click();
+
+ // exiting drops the review context and the pr-id param; the file (the
+ // live branch) stays as it was
+ await expect(page).not.toHaveURL(/pr-id/);
+ await expect(page.getByText("Reviewing pull request")).toBeHidden();
+});
diff --git a/frontend/resources/images/icons/arrow-long-right.svg b/frontend/resources/images/icons/arrow-long-right.svg
new file mode 100644
index 0000000000..1ae88ca99d
--- /dev/null
+++ b/frontend/resources/images/icons/arrow-long-right.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/git-branch-plus.svg b/frontend/resources/images/icons/git-branch-plus.svg
new file mode 100644
index 0000000000..96fbbdf693
--- /dev/null
+++ b/frontend/resources/images/icons/git-branch-plus.svg
@@ -0,0 +1,8 @@
+
diff --git a/frontend/resources/images/icons/git-branch.svg b/frontend/resources/images/icons/git-branch.svg
new file mode 100644
index 0000000000..4b05d02f68
--- /dev/null
+++ b/frontend/resources/images/icons/git-branch.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/resources/images/icons/git-commit-vertical.svg b/frontend/resources/images/icons/git-commit-vertical.svg
new file mode 100644
index 0000000000..20c117f93f
--- /dev/null
+++ b/frontend/resources/images/icons/git-commit-vertical.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/resources/images/icons/git-commit.svg b/frontend/resources/images/icons/git-commit.svg
new file mode 100644
index 0000000000..57a6ee501c
--- /dev/null
+++ b/frontend/resources/images/icons/git-commit.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/resources/images/icons/git-merge.svg b/frontend/resources/images/icons/git-merge.svg
new file mode 100644
index 0000000000..df2c075fdb
--- /dev/null
+++ b/frontend/resources/images/icons/git-merge.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/resources/images/icons/git-pull-request-arrow.svg b/frontend/resources/images/icons/git-pull-request-arrow.svg
new file mode 100644
index 0000000000..cdb291b7e9
--- /dev/null
+++ b/frontend/resources/images/icons/git-pull-request-arrow.svg
@@ -0,0 +1,7 @@
+
diff --git a/frontend/resources/images/icons/pencil.svg b/frontend/resources/images/icons/pencil.svg
new file mode 100644
index 0000000000..1295c5c7e6
--- /dev/null
+++ b/frontend/resources/images/icons/pencil.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/resources/images/icons/triangle-alert.svg b/frontend/resources/images/icons/triangle-alert.svg
new file mode 100644
index 0000000000..f07a1d0a57
--- /dev/null
+++ b/frontend/resources/images/icons/triangle-alert.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs
index 683bbcf8b9..7108b04dff 100644
--- a/frontend/src/app/main/data/viewer.cljs
+++ b/frontend/src/app/main/data/viewer.cljs
@@ -57,6 +57,7 @@
[:map {:title "initialize"}
[:file-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]
+ [:pr-id {:optional true} [:maybe ::sm/uuid]]
[:page-id {:optional true} ::sm/uuid]])
(defn initialize
@@ -118,10 +119,11 @@
[:map {:title "fetch-bundle"}
[:page-id ::sm/uuid]
[:file-id ::sm/uuid]
- [:share-id {:optional true} ::sm/uuid]])
+ [:share-id {:optional true} ::sm/uuid]
+ [:pr-id {:optional true} ::sm/uuid]])
(defn- fetch-bundle
- [{:keys [file-id share-id] :as params}]
+ [{:keys [file-id share-id pr-id] :as params}]
(dm/assert!
"expected valid params"
@@ -138,7 +140,13 @@
features cfeat/supported-features
params' (cond-> {:file-id file-id :features features}
(uuid? share-id)
- (assoc :share-id share-id))
+ (assoc :share-id share-id)
+
+ ;; pull request review sandbox: the bundle serves
+ ;; the pinned review snapshot instead of the live
+ ;; branch state
+ (uuid? pr-id)
+ (assoc :pr-id pr-id))
resolve (fn [[key pointer]]
(let [params {:file-id file-id :fragment-id @pointer}
diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs
index b04bf5394b..84ed93e937 100644
--- a/frontend/src/app/main/data/workspace.cljs
+++ b/frontend/src/app/main/data/workspace.cljs
@@ -604,6 +604,7 @@
:workspace-editor-state
:workspace-wasm-editor-styles
:workspace-media-objects
+ :workspace-pr-preview
:workspace-presence
:workspace-tokens
:workspace-undo
diff --git a/frontend/src/app/main/data/workspace/branches.cljs b/frontend/src/app/main/data/workspace/branches.cljs
new file mode 100644
index 0000000000..1a9a06c2a3
--- /dev/null
+++ b/frontend/src/app/main/data/workspace/branches.cljs
@@ -0,0 +1,481 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns app.main.data.workspace.branches
+ "Data layer for file branching (Phase 1: create + list). Mirrors the
+ patterns in `app.main.data.workspace.versions`."
+ (:require
+ [app.config :as cf]
+ [app.main.broadcast :as mbc]
+ [app.main.data.common :as dcm]
+ [app.main.data.event :as ev]
+ [app.main.data.modal :as modal]
+ [app.main.data.notifications :as ntf]
+ [app.main.data.persistence :as dwp]
+ [app.main.data.workspace.layout :as layout]
+ [app.main.repo :as rp]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [app.util.storage :as storage]
+ [beicon.v2.core :as rx]
+ [potok.v2.core :as ptk]))
+
+(defn- reload-file-window
+ "Hard-reload the browser window so the open file is re-fetched from
+ scratch. Used after merge / update-from-main, which rewrite the file
+ server-side (including repair changes); a full reload is the most
+ robust way to show the new state without local/server divergence."
+ []
+ (ptk/reify ::reload-file-window
+ ptk/EffectEvent
+ (effect [_ _ _]
+ (dom/reload-current-window))))
+
+(declare open-branch)
+
+(defn- show-merge-result
+ "After merging a branch into main, surface the merged result: if main is
+ the file currently open, hard-reload it; otherwise navigate to main."
+ [source-file-id]
+ (ptk/reify ::show-merge-result
+ ptk/WatchEvent
+ (watch [_ state _]
+ (cond
+ (nil? source-file-id) (rx/of (reload-file-window))
+ (= source-file-id (:current-file-id state)) (rx/of (reload-file-window))
+ :else (rx/of (open-branch source-file-id))))))
+
+(defonce default-state
+ {:status :loading
+ :data nil})
+
+(declare fetch-branches)
+(declare fetch-branch-context)
+(declare show-branches-panel)
+
+(defn init-branches-state
+ []
+ (ptk/reify ::init-branches-state
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc state :workspace-branches default-state))
+
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/of (fetch-branches)))))
+
+(defn update-branches-state
+ [branch-state]
+ (ptk/reify ::update-branches-state
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :workspace-branches merge branch-state))))
+
+(defn fetch-branches
+ []
+ (ptk/reify ::fetch-branches
+ ptk/WatchEvent
+ (watch [_ state _]
+ (when-let [file-id (:current-file-id state)]
+ ;; the open file may itself be a branch; branches are listed by their
+ ;; source (main), so resolve the source first (nil when on main) and
+ ;; list siblings against it, so the panel is complete from a branch too
+ (->> (rp/cmd! :get-file-branch-info {:file-id file-id})
+ (rx/mapcat (fn [info]
+ (let [root (or (:source-file-id info) file-id)]
+ (rp/cmd! :get-file-branches {:file-id root :include-archived true}))))
+ (rx/map #(update-branches-state {:status :loaded :data %}))
+ (rx/catch (fn [_]
+ (rx/of (update-branches-state {:status :loaded :data []})))))))))
+
+;; --- Dashboard popover (list a file's branches from the dashboard)
+
+(defn- set-dashboard-branches
+ [bs]
+ (ptk/reify ::set-dashboard-branches
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc state :dashboard-branches bs))))
+
+(defn load-file-branches
+ "Load the open branches of `file-id` into `:dashboard-branches` (used by
+ the dashboard branches popover). The previous list is cleared first so a
+ popover opened on another file never shows stale entries."
+ [file-id]
+ (ptk/reify ::load-file-branches
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc state :dashboard-branches []))
+
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (->> (rp/cmd! :get-file-branches {:file-id file-id})
+ (rx/map set-dashboard-branches)
+ (rx/catch (fn [_] (rx/of (set-dashboard-branches []))))))))
+
+(defn rename-branch
+ [id name]
+ (assert (uuid? id) "expected valid uuid for `id`")
+ (ptk/reify ::rename-branch
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (->> (rp/cmd! :update-file-branch {:id id :name name})
+ (rx/mapcat (fn [_] (rx/of (fetch-branches) (fetch-branch-context))))
+ (rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error")))))))))
+
+(defn set-branch-description
+ "Update a branch's free-text description (shown in the Branch info modal).
+ Refreshes the panel and the on-branch banner so the new value is visible."
+ [id description]
+ (assert (uuid? id) "expected valid uuid for `id`")
+ (ptk/reify ::set-branch-description
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (->> (rp/cmd! :update-file-branch {:id id :description description})
+ (rx/mapcat (fn [_] (rx/of (fetch-branches) (fetch-branch-context))))
+ (rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error")))))))))
+
+(defn archive-branch
+ ([id] (archive-branch id true))
+ ([id archived?]
+ (assert (uuid? id) "expected valid uuid for `id`")
+ (ptk/reify ::archive-branch
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (->> (rp/cmd! :archive-file-branch {:id id :archived archived?})
+ (rx/mapcat (fn [_] (rx/of (fetch-branches))))
+ (rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error"))))))))))
+
+(defn delete-branch
+ [id]
+ (assert (uuid? id) "expected valid uuid for `id`")
+ (ptk/reify ::delete-branch
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (->> (rp/cmd! :delete-file-branch {:id id})
+ (rx/mapcat (fn [_] (rx/of (ntf/success (tr "workspace.branches.lifecycle.deleted"))
+ (fetch-branches))))
+ (rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error")))))))))
+
+(defn create-branch
+ "Create a branch from a file. With no `file-id`, branches the currently
+ open file (force-persisting first so the merge base captures the latest
+ edits) and jumps into the new branch, keeping the branches panel open
+ so the user lands on the branch with full context. With an explicit
+ `file-id` (e.g. from the dashboard, where the file is not open),
+ creates it directly and opens the new branch. Errors surface as a
+ toast; the caller closes the dialog."
+ ([name description] (create-branch nil name description))
+ ([file-id name description]
+ (assert (string? name) "expected string for `name`")
+ (ptk/reify ::create-branch
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [current-id (:current-file-id state)
+ from-ws? (and (nil? file-id) (some? current-id))
+ target-id (or file-id current-id)
+ ;; Wait for persistence only when branching the open file;
+ ;; from the dashboard the file is not open, so there is
+ ;; nothing to flush. Emits exactly one value, so the branch
+ ;; is created exactly once.
+ wait (if from-ws?
+ (dwp/wait-persisted)
+ (rx/of :ready))]
+ (rx/concat
+ ;; Force-persist the open file first so the merge base captures
+ ;; the latest edits. The ::force-persist event must be dispatched
+ ;; at the top level (not piped into the mapcat below); off the
+ ;; workspace there is nothing to persist.
+ (if from-ws?
+ (rx/of ::dwp/force-persist (ev/event {::ev/name "create-branch"}))
+ (rx/of (ev/event {::ev/name "create-branch"})))
+ (->> wait
+ (rx/mapcat #(rp/cmd! :create-file-branch
+ {:file-id target-id
+ :name name
+ :description description}))
+ (rx/mapcat
+ (fn [{:keys [branch-file-id]}]
+ (rx/concat
+ (rx/of (ntf/success (tr "workspace.branches.create.success" name)))
+ (if from-ws?
+ ;; jump into the new branch keeping the branches
+ ;; panel open (the layout survives in-app navigation;
+ ;; show-branches-panel pins the tab selection), and
+ ;; refresh the list so the new branch is included
+ (rx/of (open-branch branch-file-id)
+ (show-branches-panel)
+ (fetch-branches))
+ ;; from dashboard: jump into the new branch
+ (rx/of (dcm/go-to-workspace :file-id branch-file-id))))))
+ (rx/catch
+ (fn [_]
+ (rx/of (ntf/error (tr "workspace.branches.create.error"))))))))))))
+
+(defn open-branch
+ "Navigate to the branch file as a normal workspace file. Switching
+ branch must never lose the branches panel: the layout flag survives
+ in-app navigation, but side effects of the transition can still close
+ it (e.g. the version-history interrupt watcher armed by the header
+ button), which makes the switch confusing. So once the target
+ workspace finishes initializing, the panel + Branches tab are
+ re-asserted; when they are already open this is an idempotent no-op."
+ [branch-file-id]
+ (assert (uuid? branch-file-id) "expected valid uuid for `branch-file-id`")
+ (ptk/reify ::open-branch
+ ptk/WatchEvent
+ (watch [_ _ stream]
+ (rx/merge
+ (rx/of (dcm/go-to-workspace :file-id branch-file-id))
+ ;; keyword literal to avoid requiring the whole app.main.data.workspace ns
+ (->> stream
+ (rx/filter (ptk/type? :app.main.data.workspace/workspace-initialized))
+ (rx/take 1)
+ (rx/mapcat (fn [_] (rx/of (show-branches-panel)
+ (fetch-branches)))))))))
+
+(def history-sidebar-tab-key
+ "Storage + broadcast key for the selected tab of the version-history
+ sidebar. Shared with `app.main.ui.workspace.sidebar`, which reads it
+ through `hooks/use-shared-state` so this event can switch the active
+ tab even when the panel is already open."
+ ::history-sidebar-tab)
+
+(defn show-branches-panel
+ "Open the version-history sidebar and switch it to the Branches tab.
+ No-op when the branching feature flag is disabled (the tab does not
+ exist in that case). Writing storage covers a closed→open panel; the
+ broadcast emit covers an already-open one."
+ []
+ (ptk/reify ::show-branches-panel
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (when (contains? cf/flags :branching)
+ (rx/of (layout/toggle-layout-flag :document-history :force? true))))
+
+ ptk/EffectEvent
+ (effect [_ _ _]
+ (when (contains? cf/flags :branching)
+ (swap! storage/user assoc history-sidebar-tab-key "branches")
+ (mbc/emit! history-sidebar-tab-key "branches")))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; COMPARE (read-only 3-way diff)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn update-branch-diff
+ [diff-state]
+ (ptk/reify ::update-branch-diff
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :workspace-branch-diff merge diff-state))))
+
+(defn fetch-branch-diff
+ "Load the 3-way diff for `branch-id`. `direction` selects which side's
+ changes to show: `:branch->main` (default, the branch's outgoing changes)
+ or `:main->branch` (main's incoming changes the branch is missing). A
+ newer fetch cancels any previous in-flight one, so a stale response can
+ never land over a direction/branch switch."
+ ([branch-id] (fetch-branch-diff branch-id :branch->main))
+ ([branch-id direction]
+ (assert (uuid? branch-id) "expected valid uuid for `branch-id`")
+ (ptk/reify ::fetch-branch-diff
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc state :workspace-branch-diff {:status :loading
+ :branch-id branch-id
+ :direction direction
+ :selected nil}))
+ ptk/WatchEvent
+ (watch [_ _ stream]
+ (->> (rp/cmd! :get-branch-diff {:branch-id branch-id :direction direction})
+ (rx/map #(update-branch-diff {:status :loaded :diff %}))
+ (rx/catch (fn [_]
+ (rx/of (update-branch-diff {:status :error}))))
+ (rx/take-until (rx/filter (ptk/type? ::fetch-branch-diff) stream)))))))
+
+(defn select-diff-change
+ "Select a change/conflict (by its index in the diff) to show its detail."
+ [selected]
+ (ptk/reify ::select-diff-change
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-branch-diff :selected] selected))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; BRANCH CONTEXT (banner when the open file is a branch)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn- set-branch-context
+ "Store the branch context of `file-id`. The state value is
+ `{:file-id .. :loaded? true :info }`, so consumers can tell
+ \"this file is not a branch\" (loaded, nil info) apart from \"not loaded
+ yet\" — see `refs/branch-context` for the `:info` lens."
+ [file-id info]
+ (ptk/reify ::set-branch-context
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc state :workspace-branch-context {:file-id file-id
+ :loaded? true
+ :info info}))))
+
+(defn fetch-branch-context
+ "Load branch metadata for the current file (nil when it is not a branch).
+ A context belonging to ANOTHER file is dropped synchronously so the
+ banner/header never show stale data while the RPC is in flight. If the
+ file is already known NOT to be a branch the RPC is skipped entirely (a
+ file cannot become a branch, and this event fires on every save/focus).
+ If `behind` grew since the previous value of the SAME file (main advanced
+ while working on the branch), surface a notification."
+ []
+ (ptk/reify ::fetch-branch-context
+ ptk/UpdateEvent
+ (update [_ state]
+ (let [file-id (:current-file-id state)
+ ctx (:workspace-branch-context state)]
+ (if (or (nil? ctx) (= file-id (:file-id ctx)))
+ state
+ (dissoc state :workspace-branch-context))))
+
+ ptk/WatchEvent
+ (watch [_ state _]
+ (when-let [file-id (:current-file-id state)]
+ (let [ctx (:workspace-branch-context state)
+ same-file? (= file-id (:file-id ctx))
+ not-branch? (and same-file? (:loaded? ctx) (nil? (:info ctx)))
+ prev-behind (if same-file? (or (:behind (:info ctx)) 0) 0)]
+ (when-not not-branch?
+ (->> (rp/cmd! :get-file-branch-info {:file-id file-id})
+ (rx/mapcat
+ (fn [info]
+ (rx/concat
+ (rx/of (set-branch-context file-id info))
+ (if (and info (> (or (:behind info) 0) prev-behind))
+ (rx/of (ntf/info (tr "workspace.branches.main-advanced")))
+ (rx/empty)))))
+ (rx/catch (fn [_] (rx/of (set-branch-context file-id nil)))))))))))
+
+(defn update-branch-from-main
+ "Bring main's changes into the branch (reverse of merge). `branch` is the
+ branch row. With conflicts and no `resolutions`, opens the conflict
+ resolution modal in update mode; with resolutions, applies them."
+ ([branch] (update-branch-from-main branch nil))
+ ([branch resolutions]
+ (ptk/reify ::update-branch-from-main
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/concat
+ (rx/of (ev/event {::ev/name "update-branch-from-main"}))
+ (->> (rp/cmd! :update-branch-from-main (cond-> {:branch-id (:id branch)}
+ (seq resolutions) (assoc :resolutions resolutions)))
+ (rx/mapcat
+ (fn [{:keys [status]}]
+ (case status
+ ;; the open branch file just changed server-side: hard-reload
+ ;; it so the pulled changes are shown
+ :updated (rx/of (ntf/success (tr "workspace.branches.update.success"))
+ (reload-file-window))
+ :conflicts (rx/of (modal/show :branch-conflicts {:branch branch :mode :update}))
+ :unsupported (rx/of (ntf/warn (tr "workspace.branches.update.unsupported")))
+ (rx/of (ntf/error (tr "workspace.branches.update.error"))))))
+ (rx/catch (fn [_]
+ (rx/of (ntf/error (tr "workspace.branches.update.error")))))))))))
+
+(defn set-conflict-resolution
+ "Choose `:main` or `:branch` for a whole conflicting entity (by id). This
+ is the per-element \"Use this\" action; it overwrites any per-attr map."
+ [id choice]
+ (ptk/reify ::set-conflict-resolution
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-branch-diff :resolutions id] choice))))
+
+(defn set-conflict-attr-resolution
+ "Choose `:main` or `:branch` for a single attribute `attr` of a
+ conflicting entity, leaving the other attributes as they were. `attrs`
+ is the full set of the conflict's changed-attr keys, used to seed a
+ complete resolution map the first time a property is toggled (so the
+ conflict counts as resolved, with untouched attrs defaulting to the safe
+ `:main` side). Promotes a prior whole-entity keyword to a per-attr map."
+ [id attr choice attrs]
+ (ptk/reify ::set-conflict-attr-resolution
+ ptk/UpdateEvent
+ (update [_ state]
+ (update-in state [:workspace-branch-diff :resolutions id]
+ (fn [cur]
+ (let [seed (cond
+ (map? cur) cur
+ (= cur :branch) (zipmap attrs (repeat :branch))
+ :else (zipmap attrs (repeat :main)))]
+ (assoc seed attr choice)))))))
+
+(defn set-all-resolutions
+ "Bulk-resolve every current conflict to `:main` or `:branch`."
+ [choice]
+ (ptk/reify ::set-all-resolutions
+ ptk/UpdateEvent
+ (update [_ state]
+ (let [conflicts (get-in state [:workspace-branch-diff :diff :conflicts])
+ res (into {} (map (fn [c] [(:id c) choice])) conflicts)]
+ (assoc-in state [:workspace-branch-diff :resolutions] res)))))
+
+(defn merge-branch
+ "Merge `branch` (a branch row/context map with at least `:id`) into main.
+ On success the branch is marked merged server-side and open clients
+ reload main via the `:file-merged` msgbus event.
+
+ Options:
+ - `:resolutions` — optional `{entity-id (:main|:branch)}` map used to
+ resolve conflicts before integrating.
+ - `:keep-branch` — when true the merged branch is kept (it shows up
+ under \"Archived\"); when false (default) the server deletes the
+ branch and its file in the same transaction, so merged copies do not
+ pile up and eat disk space.
+
+ When the current diff in state belongs to this branch, its main revn is
+ sent as `expected-main-revn` so the server refuses to apply resolutions
+ computed against a stale diff (`:file-modified`); in that case the diff
+ is re-fetched and the user is asked to review it again. Unresolved
+ conflicts open the resolution modal."
+ ([branch] (merge-branch branch nil))
+ ([branch {:keys [resolutions keep-branch]}]
+ (assert (uuid? (:id branch)) "expected a branch row with a valid `:id`")
+ (ptk/reify ::merge-branch
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [branch-id (:id branch)
+ diff-st (:workspace-branch-diff state)
+ main-revn (when (= branch-id (:branch-id diff-st))
+ (get-in diff-st [:diff :meta :main-revn]))]
+ (rx/concat
+ (rx/of (ev/event {::ev/name "merge-branch"}))
+ (->> (rp/cmd! :merge-file-branch
+ (cond-> {:branch-id branch-id}
+ (seq resolutions) (assoc :resolutions resolutions)
+ keep-branch (assoc :keep-branch true)
+ (some? main-revn) (assoc :expected-main-revn main-revn)))
+ (rx/mapcat
+ (fn [{:keys [status source-file-id]}]
+ (case status
+ ;; the merged result lives in main: take the user there
+ ;; to see it (navigate if elsewhere, hard-reload if
+ ;; already on main). Other clients reload via the
+ ;; `:file-merged` event; the branch deletion (when not
+ ;; kept) already happened server-side.
+ :merged (rx/of (ntf/success (tr "workspace.branches.merge.success"))
+ (show-merge-result source-file-id))
+ :conflicts (rx/of (modal/show :branch-conflicts {:branch branch :mode :merge}))
+ :unsupported (rx/of (ntf/warn (tr "workspace.branches.merge.unsupported")))
+ (rx/of (ntf/error (tr "workspace.branches.merge.error"))))))
+ (rx/catch
+ (fn [cause]
+ (if (= :file-modified (:code (ex-data cause)))
+ ;; main moved under our feet: reload the diff so the
+ ;; user resolves against the current state
+ (rx/of (ntf/warn (tr "workspace.branches.merge.main-moved"))
+ (fetch-branch-diff branch-id))
+ (rx/of (ntf/error (tr "workspace.branches.merge.error")))))))))))))
+
diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs
index b3eead45ef..117bd6bbac 100644
--- a/frontend/src/app/main/data/workspace/notifications.cljs
+++ b/frontend/src/app/main/data/workspace/notifications.cljs
@@ -23,6 +23,7 @@
[app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.layout :as dwly]
[app.main.data.workspace.libraries :as dwl]
+ [app.main.data.workspace.pull-requests :as dwpr]
[app.main.data.workspace.texts :as dwt]
[app.main.router :as rt]
[app.util.globals :refer [global]]
@@ -41,6 +42,8 @@
(declare handle-file-change)
(declare handle-file-deleted)
(declare handle-file-restored)
+(declare handle-file-merged)
+(declare handle-pull-request-change)
(declare handle-library-change)
(declare handle-pointer-send)
(declare handle-export-update)
@@ -136,7 +139,12 @@
:file-change (handle-file-change msg)
:file-deleted (handle-file-deleted msg)
:file-restored (handle-file-restored msg)
+ :file-merged (handle-file-merged msg)
:library-change (handle-library-change msg)
+ :pull-request-created (handle-pull-request-change msg)
+ :pull-request-updated (handle-pull-request-change msg)
+ :pull-request-review-submitted (handle-pull-request-change msg)
+ :pull-request-closed (handle-pull-request-change msg)
:notification (dc/handle-notification msg)
:team-role-change (handle-change-team-role msg)
:team-membership-change (dc/team-membership-change msg)
@@ -276,14 +284,20 @@
:undo-changes []})))))
(defn handle-file-deleted
- [{:keys [file-id] :as msg}]
+ [{:keys [file-id session-id] :as msg}]
(ptk/reify ::handle-file-deleted
ptk/WatchEvent
(watch [_ state _]
(let [curr-file-id (:current-file-id state)
team-id (:current-team-id state)]
- ;; If the deleted file is the currently open one
- (when (= file-id curr-file-id)
+ ;; If the deleted file is the currently open one, evict to the
+ ;; dashboard — EXCEPT when this session originated the deletion
+ ;; (merging a branch deletes the open branch file and the merge
+ ;; flow itself navigates to main; racing it here would land the
+ ;; user on the dashboard instead).
+ (when (and (= file-id curr-file-id)
+ (or (nil? session-id)
+ (not= session-id (:session-id state))))
(rx/of
(rt/nav :dashboard-recent {:team-id team-id})))))))
@@ -314,6 +328,27 @@
(not= vern curr-vern))
(rx/of (ptk/event ::dw/reload-current-file)))))))
+(defn handle-file-merged
+ "A branch was merged into this file (main). Reload the current file so
+ the merged changes appear for everyone with main open."
+ [{:keys [file-id] :as _msg}]
+ (ptk/reify ::handle-file-merged
+ ptk/WatchEvent
+ (watch [_ state _]
+ (when (= file-id (:current-file-id state))
+ (rx/of (ptk/event ::dw/reload-current-file))))))
+
+(defn handle-pull-request-change
+ "A pull request of this file changed (created / edited / reviewed /
+ closed): refresh the sidebar list, and the sandbox banner when it is
+ showing that pull request."
+ [{:keys [pull-request-id] :as _msg}]
+ (ptk/reify ::handle-pull-request-change
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/of (dwpr/fetch-pull-requests)
+ (dwpr/refresh-pull-request-preview-info pull-request-id)))))
+
(def ^:private schema:handle-library-change
[:map {:title "handle-library-change"}
[:type :keyword]
diff --git a/frontend/src/app/main/data/workspace/pull_requests.cljs b/frontend/src/app/main/data/workspace/pull_requests.cljs
new file mode 100644
index 0000000000..d46ed6ba2f
--- /dev/null
+++ b/frontend/src/app/main/data/workspace/pull_requests.cljs
@@ -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 INC Sucursal en España SL
+
+(ns app.main.data.workspace.pull-requests
+ "Data layer for pull requests over file branches. A pull request points
+ to a pinned review snapshot of its branch; the review sandbox is that
+ snapshot loaded read-only into the workspace (same mechanics as the
+ version-history preview in `app.main.data.workspace.versions`), so it
+ is never an editable copy of anything. Mirrors the patterns of
+ `app.main.data.workspace.branches`."
+ (:require
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.config :as cf]
+ [app.main.data.event :as ev]
+ [app.main.data.notifications :as ntf]
+ [app.main.data.persistence :as dwp]
+ [app.main.repo :as rp]
+ [app.main.router :as rt]
+ [app.util.i18n :refer [tr]]
+ [beicon.v2.core :as rx]
+ [potok.v2.core :as ptk]))
+
+(defn enabled?
+ []
+ (contains? cf/flags :branching))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; PULL REQUEST LIST (sidebar section)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defonce default-state
+ {:status :loading
+ :data nil})
+
+(defn- update-pull-requests-state
+ [pr-state]
+ (ptk/reify ::update-pull-requests-state
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :workspace-pull-requests merge pr-state))))
+
+(defn fetch-pull-requests
+ "Load the pull requests of the current file into
+ `:workspace-pull-requests`. The backend resolves a branch file id to
+ its source, so the list is complete whether the user is on main or on
+ a branch. Closed ones are included; the UI decides what to show."
+ []
+ (ptk/reify ::fetch-pull-requests
+ ptk/WatchEvent
+ (watch [_ state _]
+ (when (enabled?)
+ (when-let [file-id (:current-file-id state)]
+ (->> (rp/cmd! :get-file-pull-requests {:file-id file-id :include-closed true})
+ (rx/map #(update-pull-requests-state {:status :loaded :data %}))
+ (rx/catch (fn [_]
+ (rx/of (update-pull-requests-state {:status :loaded :data []}))))))))))
+
+(defn init-pull-requests-state
+ []
+ (ptk/reify ::init-pull-requests-state
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc state :workspace-pull-requests default-state))
+
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/of (fetch-pull-requests)))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; PENDING REVIEWS (dashboard notifications area)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn- set-pending-reviews
+ [rows]
+ (ptk/reify ::set-pending-reviews
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc state :dashboard-pending-reviews rows))))
+
+(defn fetch-pending-reviews
+ "Load into `:dashboard-pending-reviews` the open pull requests of
+ `team-id` where the current profile is a reviewer without a current
+ verdict. Feeds the dashboard notifications dropdown."
+ [team-id]
+ (ptk/reify ::fetch-pending-reviews
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (when (and (enabled?) (some? team-id))
+ (->> (rp/cmd! :get-profile-pending-reviews {:team-id team-id})
+ (rx/map set-pending-reviews)
+ (rx/catch (fn [_] (rx/of (set-pending-reviews [])))))))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; LIFECYCLE (create / edit / publish / close / reopen)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(declare refresh-pull-request-preview-info)
+
+(defn create-pull-request
+ "Create a pull request from `branch-id` towards its source file. When
+ the branch is the currently open file, pending local changes are
+ force-persisted first so the review snapshot captures them. Errors
+ surface as a toast; the caller closes the dialog."
+ [branch-id {:keys [title description reviewers]}]
+ (assert (uuid? branch-id) "expected valid uuid for `branch-id`")
+ (assert (string? title) "expected string for `title`")
+ (ptk/reify ::create-pull-request
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [branch (->> (get-in state [:workspace-branches :data])
+ (filter #(= branch-id (:id %)))
+ (first))
+ from-ws? (= (:branch-file-id branch) (:current-file-id state))
+ wait (if from-ws?
+ (dwp/wait-persisted)
+ (rx/of :ready))]
+ (rx/concat
+ (if from-ws?
+ (rx/of ::dwp/force-persist (ev/event {::ev/name "create-pull-request"}))
+ (rx/of (ev/event {::ev/name "create-pull-request"})))
+ (->> wait
+ (rx/mapcat #(rp/cmd! :create-pull-request
+ {:branch-id branch-id
+ :title title
+ :description description
+ :reviewers (vec (or reviewers []))}))
+ (rx/mapcat
+ (fn [_]
+ (rx/of (ntf/success (tr "workspace.pull-requests.create.success" title))
+ (fetch-pull-requests))))
+ (rx/catch
+ (fn [cause]
+ (if (= :pull-request-already-exists (:code (ex-data cause)))
+ (rx/of (ntf/warn (tr "workspace.pull-requests.create.already-exists")))
+ (rx/of (ntf/error (tr "workspace.pull-requests.create.error"))))))))))))
+
+(defn update-pull-request
+ "Edit a pull request's title, description or reviewer list (author or
+ team admins only, enforced server-side)."
+ [id {:keys [title description reviewers]}]
+ (assert (uuid? id) "expected valid uuid for `id`")
+ (ptk/reify ::update-pull-request
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (->> (rp/cmd! :update-pull-request
+ (cond-> {:id id}
+ (some? title) (assoc :title title)
+ (some? description) (assoc :description description)
+ (some? reviewers) (assoc :reviewers (vec reviewers))))
+ (rx/mapcat (fn [_] (rx/of (fetch-pull-requests)
+ (refresh-pull-request-preview-info id))))
+ (rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error")))))))))
+
+(defn update-pull-request-snapshot
+ "Publish the branch's current state to the reviewers (repositions the
+ review snapshot). When the branch is the currently open file, pending
+ local changes are force-persisted first."
+ [id]
+ (assert (uuid? id) "expected valid uuid for `id`")
+ (ptk/reify ::update-pull-request-snapshot
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [from-ws? (some? (:current-file-id state))
+ wait (if from-ws? (dwp/wait-persisted) (rx/of :ready))]
+ (rx/concat
+ (if from-ws?
+ (rx/of ::dwp/force-persist (ev/event {::ev/name "update-pull-request-snapshot"}))
+ (rx/of (ev/event {::ev/name "update-pull-request-snapshot"})))
+ (->> wait
+ (rx/mapcat #(rp/cmd! :update-pull-request-snapshot {:id id}))
+ (rx/mapcat
+ (fn [{:keys [updated]}]
+ (rx/of (if updated
+ (ntf/success (tr "workspace.pull-requests.publish.success"))
+ (ntf/info (tr "workspace.pull-requests.publish.up-to-date")))
+ (fetch-pull-requests))))
+ (rx/catch
+ (fn [_]
+ (rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error")))))))))))
+
+(defn submit-review
+ "Submit (or retract, with \"pending\") a verdict on a pull request. Only
+ assigned reviewers may submit (enforced server-side)."
+ [id verdict comment]
+ (assert (uuid? id) "expected valid uuid for `id`")
+ (assert (contains? #{"approved" "changes-requested" "pending"} verdict)
+ "expected a valid verdict")
+ (ptk/reify ::submit-review
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/concat
+ (rx/of (ev/event {::ev/name "submit-pull-request-review"}))
+ (->> (rp/cmd! :submit-pull-request-review
+ (cond-> {:id id :state verdict}
+ (some? comment) (assoc :comment comment)))
+ (rx/mapcat
+ (fn [_]
+ (rx/of (ntf/success (tr "workspace.pull-requests.review.submitted"))
+ (fetch-pull-requests)
+ (refresh-pull-request-preview-info id))))
+ (rx/catch
+ (fn [cause]
+ (if (= :not-a-reviewer (:code (ex-data cause)))
+ (rx/of (ntf/warn (tr "workspace.pull-requests.review.not-a-reviewer")))
+ (rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error")))))))))))
+
+(declare exit-pull-request-preview)
+
+(defn close-pull-request
+ "Close (discard) a pull request. Its sandbox stops existing; if the user
+ is currently inside it, take them back to the branch file."
+ [id]
+ (assert (uuid? id) "expected valid uuid for `id`")
+ (ptk/reify ::close-pull-request
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [inside? (= id (get-in state [:workspace-pr-preview :pr-id]))]
+ (->> (rp/cmd! :close-pull-request {:id id})
+ (rx/mapcat
+ (fn [_]
+ (rx/concat
+ (rx/of (ntf/success (tr "workspace.pull-requests.lifecycle.closed"))
+ (fetch-pull-requests))
+ (if inside?
+ (rx/of (exit-pull-request-preview))
+ (rx/empty)))))
+ (rx/catch
+ (fn [_]
+ (rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error"))))))))))
+
+(defn reopen-pull-request
+ [id]
+ (assert (uuid? id) "expected valid uuid for `id`")
+ (ptk/reify ::reopen-pull-request
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (->> (rp/cmd! :reopen-pull-request {:id id})
+ (rx/mapcat (fn [_] (rx/of (ntf/success (tr "workspace.pull-requests.lifecycle.reopened"))
+ (fetch-pull-requests))))
+ (rx/catch
+ (fn [cause]
+ (case (:code (ex-data cause))
+ :branch-not-open
+ (rx/of (ntf/warn (tr "workspace.pull-requests.lifecycle.branch-gone")))
+
+ :pull-request-already-exists
+ (rx/of (ntf/warn (tr "workspace.pull-requests.create.already-exists")))
+
+ (rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error"))))))))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; REVIEW SANDBOX (read-only preview of the pinned snapshot)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn open-pull-request
+ "Navigate into the review sandbox of `pr`: the branch file's workspace
+ with the `pr-id` query param, which loads the pinned snapshot
+ read-only once the file is initialized."
+ [pr]
+ (assert (uuid? (:id pr)) "expected a pull request row with a valid `:id`")
+ (ptk/reify ::open-pull-request
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [params (-> (rt/get-params state)
+ (assoc :team-id (:current-team-id state))
+ (assoc :file-id (:source-file-id pr))
+ (assoc :pr-id (:id pr))
+ (dissoc :page-id))]
+ (rx/of (rt/nav :workspace params))))))
+
+(defn open-pull-request-viewer
+ "Open the review sandbox in the interactions viewer (new window): the
+ viewer bundle serves the pull request's pinned snapshot, so reviewers
+ can play the prototype exactly as it was published for review."
+ [pr]
+ (assert (uuid? (:id pr)) "expected a pull request row with a valid `:id`")
+ (ptk/reify ::open-pull-request-viewer
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [params {:file-id (:source-file-id pr)
+ :page-id (:current-page-id state)
+ :pr-id (:id pr)
+ :section :interactions}]
+ (rx/of (rt/nav :viewer
+ (d/without-nils params)
+ {::rt/new-window true
+ ::rt/window-name (dm/str "viewer-" (:source-file-id pr))}))))))
+
+(defn- set-pull-request-preview
+ [preview]
+ (ptk/reify ::set-pull-request-preview
+ ptk/UpdateEvent
+ (update [_ state]
+ (if (nil? preview)
+ (dissoc state :workspace-pr-preview)
+ (update state :workspace-pr-preview merge preview)))))
+
+(defn refresh-pull-request-preview-info
+ "Refetch the pull request row backing the active sandbox (verdicts,
+ counts, outdated flag). No-op when the sandbox is not active or shows
+ another pull request. If the pull request finished meanwhile (merged
+ or cancelled), the review context is dropped: the branch is a normal
+ branch again."
+ [id]
+ (ptk/reify ::refresh-pull-request-preview-info
+ ptk/WatchEvent
+ (watch [_ state _]
+ (when (= id (get-in state [:workspace-pr-preview :pr-id]))
+ (->> (rp/cmd! :get-pull-request {:id id})
+ (rx/mapcat
+ (fn [info]
+ (if (not= "open" (:status info))
+ (rx/of (exit-pull-request-preview))
+ (rx/of (set-pull-request-preview {:info info})))))
+ (rx/catch (fn [_] (rx/empty))))))))
+
+(defn exit-pull-request-preview
+ "Leave the review context: drop the state entry and the `pr-id` query
+ param. The file itself was never altered (it is the live branch file,
+ opened normally), so no reload is needed — the branch banner simply
+ takes over again."
+ []
+ (ptk/reify ::exit-pull-request-preview
+ ptk/UpdateEvent
+ (update [_ state]
+ (dissoc state :workspace-pr-preview))
+
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [params (-> (rt/get-params state)
+ (dissoc :pr-id))]
+ (rx/of (rt/nav :workspace params))))))
+
+(defn- enter-pull-request-preview
+ "Worker for `initialize-pull-request-preview`: load the pull request
+ info and attach the review context to the open file. The file stays a
+ NORMAL workspace file — fully navigable and editable, so reviewers can
+ try and validate anything (edits go to the branch, as always); the
+ pinned snapshot backs the interactions viewer and the outdated
+ tracking, not the canvas. Cleans the url when the pull request does
+ not belong to the open file or cannot be loaded."
+ [pr-id]
+ (ptk/reify ::enter-pull-request-preview
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc state :workspace-pr-preview {:pr-id pr-id
+ :status :loading}))
+
+ ptk/WatchEvent
+ (watch [_ state _]
+ (let [file-id (:current-file-id state)]
+ (->> (rp/cmd! :get-pull-request {:id pr-id})
+ (rx/mapcat
+ (fn [info]
+ (cond
+ ;; a stale pr-id param carried over a navigation (e.g.
+ ;; landing on main after merging from the review)
+ (not= file-id (:source-file-id info))
+ (rx/of (exit-pull-request-preview))
+
+ ;; the pull request already finished (merged or
+ ;; cancelled): the branch is a normal branch again, so
+ ;; there is no review context to restore
+ (not= "open" (:status info))
+ (rx/of (exit-pull-request-preview))
+
+ :else
+ (rx/of (set-pull-request-preview {:status :loaded :info info})))))
+ (rx/catch
+ (fn [_]
+ (rx/of (ntf/warn (tr "workspace.pull-requests.preview.unavailable"))
+ (exit-pull-request-preview)))))))))
+
+(defn initialize-pull-request-preview
+ "Attach the review context for `pr-id` to the open file. Split from the
+ worker event so a re-emission (page navigation re-runs the workspace
+ effect) is a cheap no-op."
+ [pr-id]
+ (assert (uuid? pr-id) "expected valid uuid for `pr-id`")
+ (ptk/reify ::initialize-pull-request-preview
+ ptk/WatchEvent
+ (watch [_ state _]
+ (if (= pr-id (get-in state [:workspace-pr-preview :pr-id]))
+ (rx/empty)
+ (rx/of (enter-pull-request-preview pr-id))))))
diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs
index f91e51a5ba..aa442a59a4 100644
--- a/frontend/src/app/main/data/workspace/shortcuts.cljs
+++ b/frontend/src/app/main/data/workspace/shortcuts.cljs
@@ -15,6 +15,7 @@
[app.main.data.profile :as du]
[app.main.data.shortcuts :as ds]
[app.main.data.workspace :as dw]
+ [app.main.data.workspace.branches :as dwb]
[app.main.data.workspace.colors :as mdc]
[app.main.data.workspace.comments :as dwcm]
[app.main.data.workspace.drawing :as dwd]
@@ -657,6 +658,12 @@
:fn #(emit-when-no-readonly
(dw/toggle-layout-flag :document-history))}
+ :open-branches {:tooltip (ds/meta-alt "B")
+ :command (ds/ca-mod "b")
+ :subsections [:panels]
+ :fn #(emit-when-no-readonly
+ (dwb/show-branches-panel))}
+
:toggle-colorpalette {:tooltip (ds/alt "P")
:label (fn [] (tr "shortcuts.toggle-colorpalette"))
:command (ds/a-mod "p")
diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs
index 84f6bd72f2..11b00fca82 100644
--- a/frontend/src/app/main/refs.cljs
+++ b/frontend/src/app/main/refs.cljs
@@ -41,6 +41,24 @@
(def current-page-id
(l/derived (l/key :current-page-id) st/state))
+(def branch-context
+ ;; the state entry is {:file-id .. :loaded? .. :info };
+ ;; consumers only care about the branch row itself
+ (l/derived #(get-in % [:workspace-branch-context :info]) st/state))
+
+(def pull-requests
+ (l/derived :workspace-pull-requests st/state))
+
+(def pull-request-preview
+ ;; active review context: {:pr-id .. :status .. :info }; the
+ ;; open file is the live branch, opened normally — this only drives
+ ;; the review banner/actions. nil when no review is active.
+ (l/derived #(get % :workspace-pr-preview) st/state))
+
+(def pending-reviews
+ ;; dashboard: open pull requests awaiting the current profile's review
+ (l/derived #(get % :dashboard-pending-reviews) st/state))
+
(def team
(l/derived dsh/lookup-team st/state))
diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs
index b30276e6db..cadb146291 100644
--- a/frontend/src/app/main/ui.cljs
+++ b/frontend/src/app/main/ui.cljs
@@ -189,11 +189,13 @@
team-id (some-> params :team-id uuid/parse*)
file-id (some-> params :file-id uuid/parse*)
page-id (some-> params :page-id uuid/parse*)
+ pr-id (some-> params :pr-id uuid/parse*)
layout (some-> params :layout keyword)]
[:> team-container* {:team-id team-id}
[:> workspace-page* {:team-id team-id
:file-id file-id
:page-id page-id
+ :pr-id pr-id
:layout-name layout
:key file-id}]])
@@ -201,6 +203,7 @@
(let [params (get params :query)
index (some-> (rt/get-query-param params :index) parse-long)
share-id (some-> (:share-id params) uuid/parse*)
+ pr-id (some-> (:pr-id params) uuid/parse*)
section (or (some-> (:section params) keyword)
:interactions)
@@ -219,6 +222,7 @@
:section section
:index index
:share-id share-id
+ :pr-id pr-id
:interactions-mode imode
:share share}]])
diff --git a/frontend/src/app/main/ui/dashboard/branches_popover.cljs b/frontend/src/app/main/ui/dashboard/branches_popover.cljs
new file mode 100644
index 0000000000..a670742305
--- /dev/null
+++ b/frontend/src/app/main/ui/dashboard/branches_popover.cljs
@@ -0,0 +1,84 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns app.main.ui.dashboard.branches-popover
+ "Dashboard file-card popover that lists a file's branches and lets you
+ open one or create a new branch without entering the file."
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data.macros :as dm]
+ [app.common.uuid :as uuid]
+ [app.main.data.modal :as modal]
+ [app.main.data.workspace.branches :as dwb]
+ [app.main.store :as st]
+ [app.main.ui.components.dropdown :refer [dropdown]]
+ [app.main.ui.ds.buttons.button :refer [button*]]
+ [app.main.ui.ds.foundations.assets.icon :as i]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [okulary.core :as l]
+ [rumext.v2 :as mf]))
+
+(def ^:private dashboard-branches
+ (l/derived :dashboard-branches st/state))
+
+(mf/defc branches-popover*
+ [{:keys [file n]}]
+ (let [show? (mf/use-state false)
+ branches (mf/deref dashboard-branches)
+
+ on-toggle
+ (mf/use-fn
+ (mf/deps file)
+ (fn [event]
+ (dom/stop-propagation event)
+ (when-not (deref show?)
+ ;; clear the previous file's list before fetching so the
+ ;; popover never flashes another card's branches
+ (st/emit! (dwb/load-file-branches (:id file))))
+ (swap! show? not)))
+
+ on-close (mf/use-fn #(reset! show? false))
+
+ on-create
+ (mf/use-fn
+ (mf/deps file)
+ (fn [event]
+ (dom/stop-propagation event)
+ (reset! show? false)
+ (modal/show! :create-branch {:file-name (:name file) :file-id (:id file)})))
+
+ on-open-branch
+ (mf/use-fn
+ (fn [event]
+ (dom/stop-propagation event)
+ (let [bid (-> event dom/get-current-target (dom/get-data "branch") uuid/parse)]
+ (st/emit! (dwb/open-branch bid)))))]
+
+ [:div {:class (stl/css :branches-popover-wrap)
+ :on-click (fn [e] (dom/stop-propagation e))}
+ [:div {:class (stl/css :branches-badge)
+ :role "button"
+ :aria-label (tr "dashboard.branches-badge" (dm/str n))
+ :on-click on-toggle}
+ [:> i/icon* {:icon-id i/git-branch :size "s"}]
+ [:span (dm/str n)]]
+
+ [:& dropdown {:show (deref show?) :on-close on-close}
+ [:div {:class (stl/css :branches-popover)}
+ [:ul {:class (stl/css :branches-popover-list)}
+ (for [b branches]
+ [:li {:key (dm/str (:id b))
+ :class (stl/css :branches-popover-item)
+ :role "button"
+ :data-branch (dm/str (:branch-file-id b))
+ :on-click on-open-branch}
+ [:> i/icon* {:icon-id i/git-branch :size "s"}]
+ [:span {:class (stl/css :branches-popover-name)} (:name b)]
+ [:span {:class (stl/css :branches-popover-counts)}
+ (dm/str "↑" (:ahead b) " ↓" (:behind b))]])]
+ [:> button* {:variant "primary" :icon i/add :on-click on-create}
+ (tr "workspace.branches.new")]]]]))
diff --git a/frontend/src/app/main/ui/dashboard/branches_popover.scss b/frontend/src/app/main/ui/dashboard/branches_popover.scss
new file mode 100644
index 0000000000..6ef7982584
--- /dev/null
+++ b/frontend/src/app/main/ui/dashboard/branches_popover.scss
@@ -0,0 +1,86 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC Sucursal en España SL
+
+@use "ds/typography.scss" as t;
+@use "refactor/common-refactor.scss" as deprecated;
+
+// floats over the bottom-right corner of the file thumbnail
+.branches-popover-wrap {
+ position: absolute;
+ inset-inline-start: deprecated.$s-12;
+ inset-block-start: deprecated.$s-12;
+ z-index: 1;
+}
+
+.branches-badge {
+ @include t.use-typography("body-small");
+
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-xxs);
+ padding: var(--sp-xs) var(--sp-s);
+ border: 2px solid var(--color-background-quaternary);
+ border-radius: deprecated.$br-8;
+ height: 32px;
+ color: var(--color-foreground-secondary);
+ background-color: var(--color-background-tertiary);
+
+ &:hover {
+ background-color: var(--color-background-tertiary);
+ }
+}
+
+.branches-popover {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-s);
+ min-inline-size: 240px;
+ padding: var(--sp-s);
+ border-radius: 8px;
+ border: 1px solid var(--color-background-quaternary);
+ background-color: var(--color-background-secondary);
+ box-shadow: 0 4px 16px rgb(0 0 0 / 0.25);
+}
+
+.branches-popover-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ max-block-size: 280px;
+ overflow-y: auto;
+}
+
+.branches-popover-item {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-xs) var(--sp-s);
+ border-radius: 6px;
+ cursor: pointer;
+ color: var(--color-foreground-primary);
+
+ &:hover {
+ background-color: var(--color-background-tertiary);
+ }
+}
+
+.branches-popover-name {
+ @include t.use-typography("body-medium");
+
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.branches-popover-counts {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
diff --git a/frontend/src/app/main/ui/dashboard/comments.cljs b/frontend/src/app/main/ui/dashboard/comments.cljs
index 7f0da6b00e..794b41a1ce 100644
--- a/frontend/src/app/main/ui/dashboard/comments.cljs
+++ b/frontend/src/app/main/ui/dashboard/comments.cljs
@@ -7,9 +7,12 @@
(ns app.main.ui.dashboard.comments
(:require-macros [app.main.style :as stl])
(:require
+ [app.common.data.macros :as dm]
+ [app.common.uuid :as uuid]
[app.main.data.comments :as dcm]
[app.main.data.event :as ev]
[app.main.data.workspace.comments :as dwcm]
+ [app.main.data.workspace.pull-requests :as dwpr]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.comments :as cmt]
@@ -17,6 +20,7 @@
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.icons :as deprecated-icon]
+ [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[rumext.v2 :as mf]))
@@ -27,6 +31,7 @@
[{:keys [profile on-show-comments]}]
(let [threads-map (mf/deref refs/comment-threads)
+ pending (mf/deref refs/pending-reviews)
tgroups
(->> (vals threads-map)
@@ -43,12 +48,13 @@
:aria-label (tr "dashboard.notifications.view")
:on-click on-show-comments
:icon i/comments}
- (when (seq tgroups)
+ (when (or (seq tgroups) (seq pending))
[:div {:class (stl/css :unread)}])]]))
(mf/defc comments-section
[{:keys [profile team show? on-hide-comments]}]
(let [threads-map (mf/deref refs/comment-threads)
+ pending (mf/deref refs/pending-reviews)
;; FIXME: with-memo
team-id (:id team)
@@ -64,6 +70,21 @@
(st/emit! (-> (dwcm/navigate-to-comment-from-dashboard thread)
(with-meta {::ev/origin "dashboard"})))))
+ on-open-review
+ (mf/use-callback
+ (mf/deps on-hide-comments)
+ (fn [event]
+ (let [id (-> (dom/get-current-target event)
+ (dom/get-data "pr-id")
+ (uuid/parse*))
+ row (->> (deref refs/pending-reviews)
+ (filter #(= id (:id %)))
+ (first))]
+ (when row
+ (on-hide-comments)
+ (st/emit! (-> (dwpr/open-pull-request row)
+ (with-meta {::ev/origin "dashboard"})))))))
+
on-read-all
(mf/use-callback
(mf/deps team-id)
@@ -73,7 +94,8 @@
(mf/use-effect
(mf/deps team-id)
(fn []
- (st/emit! (dcm/retrieve-unread-comment-threads team-id))))
+ (st/emit! (dcm/retrieve-unread-comment-threads team-id)
+ (dwpr/fetch-pending-reviews team-id))))
(mf/use-effect
(mf/deps show?)
@@ -102,19 +124,35 @@
:on-click on-hide-comments
:icon i/close}]]
- (if (seq tgroups)
- [:div {:class (stl/css :thread-groups)}
- [:> cmt/comment-dashboard-thread-group*
- {:group (first tgroups)
- :on-thread-click on-navigate
- :show-file-name true}]
- (for [tgroup (rest tgroups)]
- [:> cmt/comment-dashboard-thread-group*
- {:group tgroup
- :on-thread-click on-navigate
- :show-file-name true
- :key (:page-id tgroup)}])]
+ [:*
+ (when (seq pending)
+ [:div {:class (stl/css :review-requests)}
+ [:span {:class (stl/css :review-requests-title)}
+ (tr "dashboard.pending-reviews.title")]
+ (for [row pending]
+ [:button {:key (dm/str (:id row))
+ :class (stl/css :review-request-item)
+ :data-pr-id (dm/str (:id row))
+ :on-click on-open-review}
+ [:span {:class (stl/css :review-request-name)} (:title row)]
+ [:span {:class (stl/css :review-request-route)}
+ (dm/str (:branch-name row) " → " (:target-name row))]])])
- [:div {:class (stl/css :thread-groups-placeholder)}
- comments-icon-svg
- (tr "labels.no-comments-available")])]]]))
+ (cond
+ (seq tgroups)
+ [:div {:class (stl/css :thread-groups)}
+ [:> cmt/comment-dashboard-thread-group*
+ {:group (first tgroups)
+ :on-thread-click on-navigate
+ :show-file-name true}]
+ (for [tgroup (rest tgroups)]
+ [:> cmt/comment-dashboard-thread-group*
+ {:group tgroup
+ :on-thread-click on-navigate
+ :show-file-name true
+ :key (:page-id tgroup)}])]
+
+ (empty? pending)
+ [:div {:class (stl/css :thread-groups-placeholder)}
+ comments-icon-svg
+ (tr "labels.no-comments-available")])]]]]))
diff --git a/frontend/src/app/main/ui/dashboard/comments.scss b/frontend/src/app/main/ui/dashboard/comments.scss
index 4c2b2b86a5..bfbb1d38fa 100644
--- a/frontend/src/app/main/ui/dashboard/comments.scss
+++ b/frontend/src/app/main/ui/dashboard/comments.scss
@@ -112,3 +112,51 @@
border: deprecated.$s-1 solid;
}
}
+
+// --- Pending review requests (pull requests awaiting my review)
+
+.review-requests {
+ display: flex;
+ flex-direction: column;
+ gap: deprecated.$s-4;
+ padding: deprecated.$s-8 deprecated.$s-12;
+ border-bottom: deprecated.$s-1 solid var(--color-background-quaternary);
+}
+
+.review-requests-title {
+ color: var(--color-foreground-secondary);
+ font-size: deprecated.$fs-11;
+ text-transform: uppercase;
+}
+
+.review-request-item {
+ display: flex;
+ flex-direction: column;
+ gap: deprecated.$s-2;
+ padding: deprecated.$s-8;
+ border: none;
+ border-radius: deprecated.$s-8;
+ background: none;
+ cursor: pointer;
+ text-align: start;
+
+ &:hover {
+ background-color: var(--color-background-tertiary);
+ }
+}
+
+.review-request-name {
+ color: var(--color-foreground-primary);
+ font-size: deprecated.$fs-14;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.review-request-route {
+ color: var(--color-foreground-secondary);
+ font-size: deprecated.$fs-11;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs
index 7a0abddea5..fc5fa9efa7 100644
--- a/frontend/src/app/main/ui/dashboard/file_menu.cljs
+++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs
@@ -7,6 +7,7 @@
(ns app.main.ui.dashboard.file-menu
(:require
[app.common.data :as d]
+ [app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.event :as-alias ev]
@@ -301,6 +302,17 @@
[:> menu-item* {:id "file-duplicate" :on-action on-duplicate}
(tr "dashboard.duplicate")])
+ (when (and (not is-lib-page?)
+ (not is-search-page?)
+ can-edit
+ (contains? cf/flags :branching))
+ [:> menu-item* {:id "file-create-branch"
+ :on-action (fn []
+ (modal/show! :create-branch
+ {:file-name (:name file)
+ :file-id (:id file)}))}
+ (tr "dashboard.create-branch")])
+
(when (and (not is-lib-page?)
(not is-search-page?)
(or (seq current-projects) (seq other-teams))
diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs
index b46a0c53c1..c8967a7134 100644
--- a/frontend/src/app/main/ui/dashboard/grid.cljs
+++ b/frontend/src/app/main/ui/dashboard/grid.cljs
@@ -25,6 +25,7 @@
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.color-bullet :as bc]
+ [app.main.ui.dashboard.branches-popover :refer [branches-popover*]]
[app.main.ui.dashboard.file-menu :refer [file-menu* file-menu-items*]]
[app.main.ui.dashboard.import :refer [use-import-file]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
@@ -504,6 +505,11 @@
[:div {:class (stl/css :grid-item-badge)}
[:> icon* {:icon-id i/library}]])
+ (when (and (contains? cf/flags :branching)
+ (not library-view?)
+ (pos? (or (:branches-count file) 0)))
+ [:> branches-popover* {:file file :n (:branches-count file)}])
+
[:div {:class (stl/css :grid-item-info)}
[:div {:class (stl/css :grid-item-meta)}
(if ^boolean editing?
diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss
index df8adf08d6..5b7c8cf9b1 100644
--- a/frontend/src/app/main/ui/dashboard/grid.scss
+++ b/frontend/src/app/main/ui/dashboard/grid.scss
@@ -409,3 +409,13 @@ $thumbnail-default-height: px2rem(168);
.grid-loader {
--icon-width: calc(var(--thumbnail-width, #{$thumbnail-default-width}) * 0.25);
}
+
+.branches-badge {
+ display: inline-flex;
+ align-items: center;
+ margin-inline-end: var(--sp-xs);
+ padding: var(--sp-xxs) var(--sp-xs);
+ border-radius: 8px;
+ color: var(--color-accent-primary);
+ background-color: var(--color-background-tertiary);
+}
diff --git a/frontend/src/app/main/ui/ds/colors.scss b/frontend/src/app/main/ui/ds/colors.scss
index aec1a8b4c3..eb23025b97 100644
--- a/frontend/src/app/main/ui/ds/colors.scss
+++ b/frontend/src/app/main/ui/ds/colors.scss
@@ -78,8 +78,11 @@ $grayish-red: #bfbfbf;
--color-accent-error: #{$red-400};
--color-background-error: #{$red-200};
--color-foreground-error: #{$red-500};
+ --color-foreground-success: #{color.scale($green-500, $lightness: 30%)};
+ --color-foreground-warning: #{color.scale($orange-500, $lightness: 10%)};
--color-accent-info: #{$blue-500};
--color-background-info: #{$blue-200};
+ --color-foreground-info: #{color.scale($blue-500, $lightness: 28%)};
--color-background-default: #{$white};
--color-accent-default: #{$gray-100};
--color-icon-default: #{$blue-teal-700};
@@ -123,8 +126,11 @@ $grayish-red: #bfbfbf;
--color-accent-error: #{$red-400};
--color-background-error: #{$red-950};
--color-foreground-error: #{$red-500};
+ --color-foreground-success: #{color.scale($green-500, $lightness: 30%)};
+ --color-foreground-warning: #{color.scale($orange-500, $lightness: 10%)};
--color-accent-info: #{$blue-500};
--color-background-info: #{$blue-950};
+ --color-foreground-info: #{color.scale($blue-500, $lightness: 28%)};
--color-background-default: #{$gray-950};
--color-accent-default: #{$gray-800};
--color-icon-default: #{$grayish-blue-500};
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
index da902d690e..c3f17bce70 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
@@ -52,6 +52,7 @@
(def ^:icon-id arrow "arrow")
(def ^:icon-id arrow-down "arrow-down")
(def ^:icon-id arrow-left "arrow-left")
+(def ^:icon-id arrow-long-right "arrow-long-right")
(def ^:icon-id arrow-right "arrow-right")
(def ^:icon-id arrow-up "arrow-up")
(def ^:icon-id arrow-up-right "arrow-up-right")
@@ -153,6 +154,12 @@
(def ^:icon-id folder "folder")
(def ^:icon-id gap-horizontal "gap-horizontal")
(def ^:icon-id gap-vertical "gap-vertical")
+(def ^:icon-id git-branch "git-branch")
+(def ^:icon-id git-branch-plus "git-branch-plus")
+(def ^:icon-id git-commit "git-commit")
+(def ^:icon-id git-commit-vertical "git-commit-vertical")
+(def ^:icon-id git-merge "git-merge")
+(def ^:icon-id git-pull-request-arrow "git-pull-request-arrow")
(def ^:icon-id graphics "graphics")
(def ^:icon-id grid "grid")
(def ^:icon-id grid-column "grid-column")
@@ -219,6 +226,7 @@
(def ^:icon-id padding-top "padding-top")
(def ^:icon-id padding-top-bottom "padding-top-bottom")
(def ^:icon-id path "path")
+(def ^:icon-id pencil "pencil")
(def ^:icon-id pentool "pentool")
(def ^:icon-id percentage "percentage")
(def ^:icon-id picker "picker")
@@ -300,6 +308,7 @@
(def ^:icon-id to-corner "to-corner")
(def ^:icon-id to-curve "to-curve")
(def ^:icon-id tree "tree")
+(def ^:icon-id triangle-alert "triangle-alert")
(def ^:icon-id unlock "unlock")
(def ^:icon-id user "user")
(def ^:icon-id variant "variant")
diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs
index 030216915b..3b53cdd9c3 100644
--- a/frontend/src/app/main/ui/viewer.cljs
+++ b/frontend/src/app/main/ui/viewer.cljs
@@ -623,11 +623,12 @@
;; --- Component: Viewer
(mf/defc viewer*
- [{:keys [file-id share-id page-id] :as props}]
- (mf/with-effect [file-id page-id share-id]
+ [{:keys [file-id share-id pr-id page-id] :as props}]
+ (mf/with-effect [file-id page-id share-id pr-id]
(let [params {:file-id file-id
:page-id page-id
- :share-id share-id}]
+ :share-id share-id
+ :pr-id pr-id}]
(st/emit! (dv/initialize params))
(fn []
(st/emit! (dv/finalize params)))))
diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs
index c626b36a29..9995b84f0d 100644
--- a/frontend/src/app/main/ui/workspace.cljs
+++ b/frontend/src/app/main/ui/workspace.cljs
@@ -13,6 +13,7 @@
[app.main.data.persistence :as dps]
[app.main.data.plugins :as dpl]
[app.main.data.workspace :as dw]
+ [app.main.data.workspace.pull-requests :as dwpr]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
@@ -32,7 +33,9 @@
[app.main.ui.workspace.palette :refer [palette*]]
[app.main.ui.workspace.plugins]
[app.main.ui.workspace.sidebar :refer [sidebar*]]
+ [app.main.ui.workspace.sidebar.branches :refer [branch-context-banner*]]
[app.main.ui.workspace.sidebar.history :refer [history-toolbox*]]
+ [app.main.ui.workspace.sidebar.pull-requests :refer [pr-review-banner*]]
[app.main.ui.workspace.tokens.export]
[app.main.ui.workspace.tokens.export.modal]
[app.main.ui.workspace.tokens.import]
@@ -86,8 +89,18 @@
(fn [size]
(reset! palete-size size)))
- node-ref (use-resize-observer on-resize)]
+ node-ref (use-resize-observer on-resize)
+
+ pr-preview (mf/deref refs/pull-request-preview)]
[:*
+ (when (not ^boolean hide-ui?)
+ ;; inside a review sandbox the pull request banner replaces the
+ ;; branch banner: the user is reviewing a pinned snapshot, not
+ ;; editing the branch
+ (if (some? pr-preview)
+ [:> pr-review-banner* {}]
+ [:> branch-context-banner* {:file-id (get file :id)}]))
+
(when (not ^boolean hide-ui?)
[:> palette* {:layout layout
:on-change-size on-resize-palette}])
@@ -195,7 +208,7 @@
(mf/defc workspace*
{::mf/wrap [mf/memo]}
- [{:keys [team-id project-id file-id page-id layout-name]}]
+ [{:keys [team-id project-id file-id page-id pr-id layout-name]}]
(let [layout (mf/deref refs/workspace-layout)
wglobal (mf/deref refs/workspace-global)
@@ -249,6 +262,15 @@
(when (and file-loaded? (not page-id))
(st/emit! (dcm/go-to-workspace :file-id file-id ::rt/replace true))))
+ ;; Review context: once the (branch) file is initialized, attach the
+ ;; pull request info so the review banner and actions show up; the
+ ;; file itself stays a normal, editable workspace file. The event is
+ ;; idempotent per pr-id, so page navigation re-running this effect
+ ;; is harmless.
+ (mf/with-effect [file-id page-id pr-id file-loaded?]
+ (when (and file-loaded? page-id pr-id)
+ (st/emit! (dwpr/initialize-pull-request-preview pr-id))))
+
(mf/with-effect [file-id page-id]
(reset! first-frame-rendered? false))
diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs
index 37e6247b87..6f56819f77 100644
--- a/frontend/src/app/main/ui/workspace/left_header.cljs
+++ b/frontend/src/app/main/ui/workspace/left_header.cljs
@@ -15,6 +15,8 @@
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.store :as st]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
+ [app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.workspace.main-menu :as main-menu]
[app.util.dom :as dom]
@@ -31,6 +33,32 @@
file-name (:name file)
project-id (:id project)
shared? (:is-shared file)
+
+ ;; When the open file is a branch, `branch-ctx` is non-nil (the
+ ;; main file is never a branch, so this is also how we tell them
+ ;; apart). On a branch we show " ()" and a
+ ;; badge; on main we keep the plain file name.
+ branch-ctx (mf/deref refs/branch-context)
+ branch? (and (some? branch-ctx) (some? (:source-name branch-ctx)))
+
+ ;; When a pull request review sandbox is active the header gets a
+ ;; distinct badge and title so the snapshot is never mistaken for
+ ;; the editable branch.
+ pr-preview (mf/deref refs/pull-request-preview)
+ review? (some? pr-preview)
+ pr-title (get-in pr-preview [:info :title] "")
+
+ display-name
+ (cond
+ ^boolean review?
+ (tr "workspace.pull-requests.header-title" pr-title)
+
+ ^boolean branch?
+ (dm/str (:source-name branch-ctx) " (" (:name branch-ctx) ")")
+
+ :else
+ file-name)
+
persistence
(mf/deref refs/persistence)
@@ -108,8 +136,10 @@
:default-value (:name file "")}]
[:div
{:class (stl/css :file-name)
- :title file-name
- :on-double-click start-editing-name}
+ :title display-name
+ ;; on a branch the name is fixed ("File (Branch)"); renaming here
+ ;; would be confusing, so double-click editing is disabled.
+ :on-double-click (when-not ^boolean branch? start-editing-name)}
;; Persistence state widget
[:div {:class (case persistence-status
:pending (stl/css :status-notification :pending-status)
@@ -132,7 +162,19 @@
:retrying deprecated-icon/status-alert
:error deprecated-icon/status-wrong
nil)]
- [:div {:class (stl/css :file-name-label)} file-name]])]
+ (cond
+ ^boolean review?
+ [:> tooltip* {:content (tr "workspace.pull-requests.header-badge-tooltip" pr-title)
+ :placement "bottom"}
+ [:span {:class (stl/css :review-badge)}
+ [:> icon* {:icon-id i/git-pull-request-arrow :size "s"}]]]
+
+ ^boolean branch?
+ [:> tooltip* {:content (tr "workspace.branches.header-badge-tooltip" (:name branch-ctx))
+ :placement "bottom"}
+ [:span {:class (stl/css :branch-badge)}
+ [:> icon* {:icon-id i/git-branch :size "s"}]]])
+ [:div {:class (stl/css :file-name-label)} display-name]])]
(when ^boolean shared?
[:span {:class (stl/css :shared-badge)} deprecated-icon/library])
[:div {:class (stl/css :menu-section)}
diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss
index 663c7465b2..3738ae7f78 100644
--- a/frontend/src/app/main/ui/workspace/left_header.scss
+++ b/frontend/src/app/main/ui/workspace/left_header.scss
@@ -82,6 +82,32 @@
}
}
+.branch-badge {
+ @include deprecated.flex-center;
+
+ flex-shrink: 0;
+ width: deprecated.$s-16;
+ height: deprecated.$s-16;
+ margin-right: deprecated.$s-6;
+ border-radius: deprecated.$br-4;
+ background-color: var(--button-secondary-background-color-rest);
+ color: var(--button-secondary-foreground-color-rest);
+}
+
+// pull request review sandbox: accent-colored so the read-only snapshot
+// is never mistaken for the editable branch
+.review-badge {
+ @include deprecated.flex-center;
+
+ flex-shrink: 0;
+ width: deprecated.$s-16;
+ height: deprecated.$s-16;
+ margin-right: deprecated.$s-6;
+ border-radius: deprecated.$br-4;
+ background-color: var(--button-primary-background-color-rest);
+ color: var(--button-primary-foreground-color-rest);
+}
+
.shared-badge {
@include deprecated.flex-center;
diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs
index 5a64cf76c9..042a679e67 100644
--- a/frontend/src/app/main/ui/workspace/main_menu.cljs
+++ b/frontend/src/app/main/ui/workspace/main_menu.cljs
@@ -22,6 +22,7 @@
[app.main.data.profile :as du]
[app.main.data.shortcuts :as scd]
[app.main.data.workspace :as dw]
+ [app.main.data.workspace.branches :as dwb]
[app.main.data.workspace.comments :as dwcm]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.mcp :as mcp]
@@ -628,6 +629,18 @@
(when (kbd/enter? event)
(on-show-version-history event))))
+ on-show-branches
+ (mf/use-fn
+ (fn [_]
+ (st/emit! (dwb/show-branches-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 +731,15 @@
(tr "dashboard.show-version-history")]
[:> shortcuts* {:id :toggle-history}]]
+ (when (contains? cf/flags :branching)
+ [:> 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-show-branches"}
+ [:span {:class (stl/css :item-name)}
+ (tr "workspace.branches.menu")]
+ [:> shortcuts* {:id :open-branches}]])
+
[:div {:class (stl/css :separator)}]])
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item)
diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs
index c8a0ece18e..0f136b1e8c 100644
--- a/frontend/src/app/main/ui/workspace/sidebar.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar.cljs
@@ -20,6 +20,7 @@
[app.main.data.style-dictionary :as sd]
[app.main.data.tokenscript :as ts]
[app.main.data.workspace :as dw]
+ [app.main.data.workspace.branches :as dwb]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -33,6 +34,7 @@
[app.main.ui.workspace.left-header :refer [left-header*]]
[app.main.ui.workspace.right-header :refer [right-header*]]
[app.main.ui.workspace.sidebar.assets :refer [assets-toolbox*]]
+ [app.main.ui.workspace.sidebar.branches :refer [branches-toolbox*]]
[app.main.ui.workspace.sidebar.debug :refer [debug-panel*]]
[app.main.ui.workspace.sidebar.history :refer [history-toolbox*]]
[app.main.ui.workspace.sidebar.layers :refer [layers-toolbox*]]
@@ -242,7 +244,7 @@
::mf/memo true}
[]
(let [selected*
- (hooks/use-persisted-state ::history-sidebar "history")
+ (hooks/use-shared-state dwb/history-sidebar-tab-key "history")
selected
(deref selected*)
@@ -253,10 +255,13 @@
tabs
(mf/with-memo []
- [{:label (tr "workspace.versions.tab.history")
- :id "history"}
- {:label (tr "workspace.versions.tab.actions")
- :id "actions"}])
+ (cond-> [{:label (tr "workspace.versions.tab.history")
+ :id "history"}
+ {:label (tr "workspace.versions.tab.actions")
+ :id "actions"}]
+ (contains? cf/flags :branching)
+ (conj {:label (tr "workspace.branches.tab")
+ :id "branches"})))
button
(mf/with-memo []
@@ -280,7 +285,11 @@
"actions"
[:article {:class (stl/css :versions-tab)}
- [:> history-toolbox*]])]))
+ [:> history-toolbox*]]
+
+ "branches"
+ [:article {:class (stl/css :history-tab)}
+ [:> branches-toolbox* {}]])]))
(mf/defc right-sidebar*
[{:keys [layout section file-id page-id drawing-tool active-tokens] :as props}]
diff --git a/frontend/src/app/main/ui/workspace/sidebar/branches.cljs b/frontend/src/app/main/ui/workspace/sidebar/branches.cljs
new file mode 100644
index 0000000000..46dd211634
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/sidebar/branches.cljs
@@ -0,0 +1,1793 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License v. 2.0. If a copy of the MPL was not distributed with this
+;; file You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns app.main.ui.workspace.sidebar.branches
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data.macros :as dm]
+ [app.common.files.branch-merge :as bm]
+ [app.common.time :as ct]
+ [app.config :as cf]
+ [app.main.data.modal :as modal]
+ [app.main.data.workspace.branches :as dwb]
+ [app.main.data.workspace.pull-requests :as dwpr]
+ [app.main.refs :as refs]
+ [app.main.render :as render]
+ [app.main.store :as st]
+ [app.main.ui.components.dropdown-menu :refer [dropdown-menu*
+ dropdown-menu-item*]]
+ [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.foundations.assets.icon :as i]
+ [app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
+ [app.main.ui.ds.product.avatar :refer [avatar*]]
+ [app.main.ui.ds.product.empty-state :refer [empty-state*]]
+ [app.main.ui.workspace.sidebar.pull-requests :as prui :refer [pr-state-badge*]]
+ [app.util.color :as uc]
+ [app.util.dom :as dom]
+ [app.util.globals :as globals]
+ [app.util.i18n :refer [tr]]
+ [app.util.keyboard :as kbd]
+ [cuerdas.core :as str]
+ [goog.events :as events]
+ [okulary.core :as l]
+ [rumext.v2 :as mf]))
+
+(def ^:private branches
+ (l/derived :workspace-branches st/state))
+
+(def ^:private branch-diff
+ (l/derived :workspace-branch-diff st/state))
+
+(def ^:private branch-context
+ ;; see refs/branch-context: the state entry wraps the row under :info
+ (l/derived #(get-in % [:workspace-branch-context :info]) st/state))
+
+(def ^:private kind->icon
+ {:shape i/board
+ :component i/component
+ :color i/picker
+ :typography i/text
+ :media i/img
+ :page i/document
+ :page-attrs i/document
+ :page-order i/document
+ :page-guide i/document
+ :page-flow i/document
+ :page-grid i/document
+ :page-plugin i/document
+ :token i/tokens
+ :token-set i/tokens
+ :token-set-rename i/tokens
+ :token-set-order i/tokens
+ :token-theme i/tokens
+ :token-active-themes i/tokens
+ :token-active-sets i/tokens})
+
+(def ^:private status->label
+ {:added "workspace.branches.status.added"
+ :modified "workspace.branches.status.modified"
+ :deleted "workspace.branches.status.deleted"
+ :conflict "workspace.branches.status.conflict"})
+
+(defn- short-str
+ [v]
+ (let [s (pr-str v)]
+ (if (> (count s) 40)
+ (str (subs s 0 40) "…")
+ s)))
+
+(def ^:private attr->label
+ "Friendlier names for attrs whose raw key is unclear; every other attr
+ is humanized generically (kebab-case -> Capitalized words)."
+ {:rx "Corner radius" :ry "Corner radius"
+ :grow-type "Text auto-grow" :blend-mode "Blend mode"
+ :shape-ref "Component reference" :component-id "Component"
+ :component-file "Component library" :hidden "Visibility"
+ :blocked "Locked" :proportion-lock "Lock proportions"
+ :parent-id "Parent" :frame-id "Container"})
+
+(defn- attr-label
+ "Human-readable label for a changed property key."
+ [attr]
+ (or (get attr->label attr)
+ (-> (name attr) (str/replace #"-" " ") str/capital)))
+
+;; --- Compare: grouping by category + per-item type labels
+
+(def ^:private kind->category
+ {:shape :pages :page :pages :page-attrs :pages :page-order :pages
+ :page-guide :pages :page-flow :pages :page-grid :pages :page-plugin :pages
+ :component :components
+ :color :colors
+ :typography :typographies
+ :media :media
+ :token :tokens :token-set :tokens :token-set-rename :tokens :token-set-order :tokens
+ :token-theme :tokens :token-active-themes :tokens :token-active-sets :tokens})
+
+(def ^:private category-order [:pages :components :colors :typographies :media :tokens])
+
+(def ^:private category->icon
+ {:pages i/document :components i/component :colors i/picker
+ :typographies i/text :media i/img :tokens i/tokens})
+
+(def ^:private category->label
+ {:pages "workspace.branches.compare.group.pages"
+ :components "workspace.branches.compare.group.components"
+ :colors "workspace.branches.compare.group.colors"
+ :typographies "workspace.branches.compare.group.typographies"
+ :media "workspace.branches.compare.group.media"
+ :tokens "workspace.branches.compare.group.tokens"})
+
+(def ^:private kind->type-label
+ {:shape "workspace.branches.compare.type.shape"
+ :component "workspace.branches.compare.type.component"
+ :color "workspace.branches.compare.type.color"
+ :typography "workspace.branches.compare.type.typography"
+ :media "workspace.branches.compare.type.media"
+ :token "workspace.branches.compare.type.token"})
+
+(def ^:private shape-type->icon
+ {:frame "board" :rect "rectangle" :line "rectangle" :circle "ellipse"
+ :path "path" :text "text" :image "img" :svg-raw "img" :group "group"
+ :bool "boolean-union"})
+
+(defn- shape-icon-id
+ "Type-accurate icon for a shape diff entry (component nature wins over
+ raw type), falling back to the board icon for unknown types."
+ [{:keys [shape-type component? component-copy? variant? masked?]}]
+ (cond
+ variant? "variant"
+ component? "component"
+ component-copy? "component-copy"
+ (and (= :group shape-type) masked?) "mask"
+ :else (get shape-type->icon shape-type "board")))
+
+(def ^:private shape-type->label
+ {:frame "workspace.branches.compare.shape.board"
+ :rect "workspace.branches.compare.shape.rect"
+ :line "workspace.branches.compare.shape.rect"
+ :circle "workspace.branches.compare.shape.ellipse"
+ :path "workspace.branches.compare.shape.path"
+ :text "workspace.branches.compare.shape.text"
+ :image "workspace.branches.compare.shape.image"
+ :svg-raw "workspace.branches.compare.shape.image"
+ :group "workspace.branches.compare.shape.group"
+ :bool "workspace.branches.compare.shape.bool"})
+
+(defn- type-label-key
+ "i18n key for an item's specific type — type-accurate for shapes
+ (board/text/rectangle/component/…), generic for other kinds."
+ [item]
+ (if (= :shape (:kind item))
+ (let [{:keys [component? component-copy? variant? shape-type]} item]
+ (cond
+ variant? "workspace.branches.compare.shape.variant"
+ component? "workspace.branches.compare.shape.component"
+ component-copy? "workspace.branches.compare.shape.component-copy"
+ :else (get shape-type->label shape-type
+ "workspace.branches.compare.type.shape")))
+ (get kind->type-label (:kind item))))
+
+(defn- item-status
+ "Conflicts carry `:conflict`; otherwise the regular add/mod/del status."
+ [item]
+ (if (= :conflict (:status item)) :conflict (:status item)))
+
+(defn- hex-color
+ "Return a usable hex string when `v` looks like a color (a hex string, a
+ color map, or a fills/strokes vector), else nil."
+ [v]
+ (let [s (cond
+ (string? v) v
+ (and (map? v) (string? (:color v))) (:color v)
+ (and (map? v) (string? (:fill-color v))) (:fill-color v)
+ (and (map? v) (string? (:stroke-color v))) (:stroke-color v)
+ (and (sequential? v) (map? (first v)))
+ (or (:fill-color (first v)) (:stroke-color (first v)) (:color (first v)))
+ :else nil)]
+ ;; only valid hex lengths (3/4/6/8 digits); a bare string must carry
+ ;; the leading `#`, otherwise casual words like "cafe" become swatches
+ (when (and (string? s)
+ (re-matches #"#[0-9a-fA-F]{8}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3,4}" s))
+ s)))
+
+(defn- fmt-number
+ "At most two decimals, trailing zeros stripped (89.99999 -> \"90\",
+ 439.4999 -> \"439.5\", 386 -> \"386\")."
+ [v]
+ (-> (.toFixed v 2)
+ (str/replace #"\.?0+$" "")))
+
+(defn- display-val
+ "Compact, human-readable rendering of a property value. Raw uuids and
+ collections are summarized rather than dumped (they carry no meaning to
+ the user as raw data)."
+ [v]
+ (cond
+ (nil? v) "—"
+ (string? v) (if (> (count v) 32) (str (subs v 0 32) "…") v)
+ (number? v) (fmt-number v)
+ (boolean? v) (if v "true" "false")
+ (keyword? v) (name v)
+ (ct/inst? v) (ct/format-inst v :localized-date-time)
+ (uuid? v) (str (subs (str v) 0 8) "…")
+ (sequential? v) (tr "workspace.branches.compare.n-items" (dm/str (count v)))
+ (map? v) (tr "workspace.branches.compare.n-props" (dm/str (count v)))
+ :else (short-str v)))
+
+(defn- status-matches?
+ [filter status]
+ (case filter
+ :all true
+ :added (= status :added)
+ :modified (contains? #{:modified :conflict} status)
+ :deleted (= status :deleted)
+ true))
+
+(def ^:private compare-filters
+ [[:all "labels.all"]
+ [:added "workspace.branches.status.added"]
+ [:modified "workspace.branches.status.modified"]
+ [:deleted "workspace.branches.status.deleted"]])
+
+;; --- Create branch dialog (modal)
+
+(mf/defc create-branch-dialog*
+ {::mf/register modal/components
+ ::mf/register-as :create-branch}
+ [{:keys [file-name file-id]}]
+ (let [branch-name* (mf/use-state "")
+ description* (mf/use-state "")
+ branch-name (deref branch-name*)
+ description (deref description*)
+ valid? (not (str/blank? branch-name))
+
+ on-name-change
+ (mf/use-fn #(reset! branch-name* (dom/get-target-val %)))
+
+ on-description-change
+ (mf/use-fn #(reset! description* (dom/get-target-val %)))
+
+ on-close
+ (mf/use-fn #(st/emit! (modal/hide)))
+
+ on-submit
+ (mf/use-fn
+ (mf/deps branch-name description valid? file-id)
+ (fn [_]
+ (when valid?
+ (st/emit! (dwb/create-branch file-id (str/trim branch-name) (str/trim description))
+ (modal/hide)))))]
+
+ [:div {:class (stl/css :modal-overlay)}
+ [:div {:class (stl/css :modal-container)}
+ [:div {:class (stl/css :modal-header)}
+ [:div {:class (stl/css :modal-header-icon)}
+ [:> i/icon* {:icon-id i/git-branch}]]
+ [:div {:class (stl/css :modal-header-text)}
+ [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.create.title")]
+ [:span {:class (stl/css :modal-subtitle)}
+ (tr "workspace.branches.create.subtitle" (or file-name ""))]]
+ [:> button* {:variant "ghost"
+ :icon i/close
+ :aria-label (tr "labels.close")
+ :on-click on-close}]]
+
+ [:div {:class (stl/css :modal-content)}
+ [:> input* {:label (tr "workspace.branches.create.name-label")
+ :icon i/git-branch
+ :placeholder (tr "workspace.branches.create.name-placeholder")
+ :value branch-name
+ :on-change on-name-change}]
+
+ [:div {:class (stl/css :field)}
+ [:label {:class (stl/css :field-label)}
+ (tr "workspace.branches.create.description-label")]
+ [:textarea {:class (stl/css :textarea)
+ :value description
+ :on-change on-description-change
+ :rows 2}]]
+
+ [:div {:class (stl/css :field)}
+ [:label {:class (stl/css :field-label)}
+ (tr "workspace.branches.create.derive-from")]
+ [:div {:class (stl/css :derive-from)}
+ [:span {:class (stl/css :derive-from-dot)}]
+ [:span {:class (stl/css :derive-from-name)}
+ (tr "workspace.branches.create.derive-from-main")]
+ [:span {:class (stl/css :derive-from-hint)}
+ (tr "workspace.branches.create.derive-from-latest")]]]
+
+ [:> context-notification* {:level :info :type :context}
+ (tr "workspace.branches.create.info")]]
+
+ [:div {:class (stl/css :modal-footer)}
+ [:> button* {:variant "secondary" :on-click on-close}
+ (tr "labels.cancel")]
+ [:> button* {:variant "primary"
+ :icon i/git-branch-plus
+ :disabled (not valid?)
+ :on-click on-submit}
+ (tr "workspace.branches.create.submit")]]]]))
+
+;; --- Merge confirmation dialog (modal)
+
+(mf/defc merge-branch-dialog*
+ {::mf/register modal/components
+ ::mf/register-as :merge-branch}
+ [{:keys [branch]}]
+ (let [archive?* (mf/use-state false)
+ archive? (deref archive?*)
+
+ ;; The branch name and "main" are highlighted (foreground-primary)
+ ;; within the otherwise secondary-colored prompt. We wrap each of
+ ;; them with a sentinel in the `tr` args and split on it, so the
+ ;; whole sentence stays in a single translation and the (user
+ ;; controlled) branch name is rendered as escaped text, not HTML.
+ message-parts
+ (let [sep "\uE000"
+ msg (tr "workspace.branches.merge.confirm-message"
+ (str sep "\"" (:name branch) "\"" sep)
+ (str sep (tr "workspace.branches.main") sep))]
+ (str/split msg sep))
+
+ on-toggle-archive
+ (mf/use-fn #(swap! archive?* not))
+
+ on-close
+ (mf/use-fn #(st/emit! (modal/hide)))
+
+ on-submit
+ (mf/use-fn
+ (mf/deps branch archive?)
+ (fn [_]
+ (st/emit! (dwb/merge-branch branch {:keep-branch archive?})
+ (modal/hide))))]
+
+ [:div {:class (stl/css :modal-overlay)}
+ [:div {:class (stl/css :modal-container)}
+ [:div {:class (stl/css :modal-header)}
+ [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.merge.confirm-title")]
+ [:> button* {:variant "ghost"
+ :icon i/close
+ :aria-label (tr "labels.close")
+ :on-click on-close}]]
+
+ [:div {:class (stl/css :modal-content)}
+ [:p {:class (stl/css :modal-message)}
+ (for [[idx part] (map-indexed vector message-parts)]
+ (if (odd? idx)
+ [:span {:key idx :class (stl/css :modal-message-emphasis)} part]
+ [:span {:key idx} part]))]
+
+ [:> checkbox* {:id "merge-archive-branch"
+ :label (tr "workspace.branches.merge.archive-label")
+ :checked archive?
+ :on-change on-toggle-archive}]
+
+ (if archive?
+ [:> context-notification* {:level :warning :type :context}
+ (tr "workspace.branches.merge.archive-warning")]
+ [:> context-notification* {:level :info :type :context}
+ (tr "workspace.branches.merge.delete-info")])]
+
+ [:div {:class (stl/css :modal-footer)}
+ [:> button* {:variant "secondary" :on-click on-close}
+ (tr "labels.cancel")]
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :on-click on-submit}
+ (tr "workspace.branches.merge.confirm-accept")]]]]))
+
+;; --- Confirmation for relevant branch actions (merge / update)
+
+(defn- confirm-merge!
+ "Open the merge dialog for `branch` (merge into main is irreversible; the
+ dialog also lets the user choose whether to keep the branch archived)."
+ [branch]
+ (modal/show! :merge-branch {:branch branch}))
+
+(defn- confirm-update!
+ "Ask for confirmation before updating `branch` from main (overwrites
+ conflicting branch changes)."
+ [branch]
+ (st/emit! (modal/show {:type :confirm
+ :title (tr "workspace.branches.update.confirm-title")
+ :message (tr "workspace.branches.update.confirm-message" (:name branch))
+ :accept-label (tr "workspace.branches.update.confirm-accept")
+ :accept-style :primary
+ :on-accept (fn [_] (st/emit! (dwb/update-branch-from-main branch)))})))
+
+;; --- Branch info dialog (read-only info card + editable description)
+
+(mf/defc branch-info-dialog*
+ {::mf/register modal/components
+ ::mf/register-as :branch-info}
+ [{:keys [branch]}]
+ (let [profile (mf/deref refs/profile)
+ profiles (mf/deref refs/profiles)
+ author (or (get profiles (:created-by branch))
+ (when (= (:created-by branch) (:id profile)) profile))
+ author? (= (:created-by branch) (:id profile))
+ status (:status branch)
+ merged? (= "merged" status)
+ archived? (contains? #{"archived" "merged"} status)
+
+ editing? (mf/use-state false)
+ desc* (mf/use-state (or (:description branch) ""))
+ desc (deref desc*)
+
+ on-close (mf/use-fn #(st/emit! (modal/hide)))
+ on-edit (mf/use-fn #(reset! editing? true))
+ on-desc-change (mf/use-fn #(reset! desc* (dom/get-target-val %)))
+ on-cancel (mf/use-fn (mf/deps branch)
+ (fn [_]
+ (reset! desc* (or (:description branch) ""))
+ (reset! editing? false)))
+ on-save (mf/use-fn (mf/deps branch desc)
+ (fn [_]
+ (st/emit! (dwb/set-branch-description (:id branch) (str/trim desc)))
+ (reset! editing? false)))
+ on-compare (mf/use-fn (mf/deps branch)
+ #(modal/show! :branch-compare {:branch branch}))]
+ [:div {:class (stl/css :modal-overlay)}
+ [:div {:class (stl/css :modal-container :info-container)}
+ [:div {:class (stl/css :modal-header)}
+ [:div {:class (stl/css :modal-header-icon)}
+ [:> i/icon* {:icon-id i/git-branch}]]
+ [:div {:class (stl/css :modal-header-text)}
+ [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.info.title")]
+ [:span {:class (stl/css :modal-subtitle)} (:name branch)]]
+ [:span {:class (stl/css-case :info-status true
+ :status-merged merged?
+ :status-archived (= "archived" status)
+ :status-open (= "open" status))}
+ (tr (case status
+ "merged" "workspace.branches.info.status.merged"
+ "archived" "workspace.branches.info.status.archived"
+ "workspace.branches.info.status.open"))]
+ [:> button* {:variant "ghost"
+ :icon i/close
+ :aria-label (tr "labels.close")
+ :on-click on-close}]]
+
+ [:div {:class (stl/css :modal-content)}
+ ;; description: shown read-only, editable inline by the author
+ [:div {:class (stl/css :info-section)}
+ [:div {:class (stl/css :info-section-head)}
+ [:span {:class (stl/css :field-label)} (tr "workspace.branches.info.description")]
+ (when (and author? (not (deref editing?)))
+ [:> button* {:variant "ghost" :on-click on-edit} (tr "labels.edit")])]
+ (if (deref editing?)
+ [:div {:class (stl/css :info-desc-edit)}
+ [:textarea {:class (stl/css :textarea)
+ :value desc
+ :on-change on-desc-change
+ :rows 3
+ :auto-focus true
+ :placeholder (tr "workspace.branches.info.description-placeholder")}]
+ [:div {:class (stl/css :info-desc-actions)}
+ [:> button* {:variant "secondary" :on-click on-cancel} (tr "labels.cancel")]
+ [:> button* {:variant "primary" :on-click on-save} (tr "labels.save")]]]
+ (if (str/blank? (:description branch))
+ [:p {:class (stl/css :info-desc-empty)} (tr "workspace.branches.info.description-empty")]
+ [:p {:class (stl/css :info-desc)} (:description branch)]))]
+
+ ;; metadata
+ [:dl {:class (stl/css :info-grid)}
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.author")]
+ [:dd {:class (stl/css :info-val)}
+ (when author [:> avatar* {:profile author :variant "S"}])
+ [:span (or (:fullname author) "—")]]]
+
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.derived-from")]
+ [:dd {:class (stl/css :info-val)}
+ [:> i/icon* {:icon-id i/git-commit-vertical :size "s"}]
+ [:span (or (:source-name branch) (tr "workspace.branches.main"))]]]
+
+ (when (:created-at branch)
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.created")]
+ [:dd {:class (stl/css :info-val)}
+ (-> (:created-at branch) ct/inst (ct/format-inst :localized-date-time))]])
+
+ (when (:updated-at branch)
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.updated")]
+ [:dd {:class (stl/css :info-val)}
+ (some-> (:updated-at branch) ct/inst ct/timeago)]])
+
+ (when-not archived?
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.changes")]
+ [:dd {:class (stl/css :info-val :info-counts)}
+ [:span {:class (stl/css :count-ahead)}
+ [:> i/icon* {:icon-id i/arrow-up :size "s"}] (dm/str (:ahead branch 0))]
+ [:span {:class (stl/css :count-behind)}
+ [:> i/icon* {:icon-id i/arrow-down :size "s"}] (dm/str (:behind branch 0))]
+ (when (pos? (:conflicts branch 0))
+ [:span {:class (stl/css :item-badge :badge-conflict)}
+ (tr "workspace.branches.banner.conflicts" (dm/str (:conflicts branch)))])]])
+
+ (when (:merged-at branch)
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.merged")]
+ [:dd {:class (stl/css :info-val)}
+ (-> (:merged-at branch) ct/inst (ct/format-inst :localized-date-time))]])]]
+
+ [:div {:class (stl/css :modal-footer)}
+ (when-not archived?
+ [:> button* {:variant "secondary" :icon i/switch :on-click on-compare}
+ (tr "workspace.branches.compare")])
+ [:> button* {:variant "primary" :on-click on-close} (tr "labels.close")]]]]))
+
+;; --- Branch list entry
+
+(mf/defc branch-entry*
+ {::mf/private true}
+ [{:keys [entry profiles current pr menu-open? on-set-menu]}]
+ (let [author (get profiles (:created-by entry))
+ ahead (:ahead entry)
+ behind (:behind entry)
+ main? (:is-main entry)
+ archived? (contains? #{"archived" "merged"} (:status entry))
+
+ ;; `pr` is the branch's open pull request (nil when none): the
+ ;; review is a STATE of the branch, so the entry surfaces it as a
+ ;; badge and swaps "Request review" for the review actions
+ profile (mf/deref refs/profile)
+ team (mf/deref refs/team)
+ author? (and pr (= (:created-by pr) (:id profile)))
+ admin? (boolean (get-in team [:permissions :is-admin]))
+ can-merge? (boolean (get-in team [:permissions :can-edit]))
+
+ [approvals total] (if pr (prui/current-approvals pr) [0 0])
+
+ editing? (mf/use-state false)
+
+ on-open
+ (mf/use-fn
+ (mf/deps entry editing?)
+ (fn [_]
+ (when-not (deref editing?)
+ (st/emit! (dwb/open-branch (:branch-file-id entry))))))
+
+ on-compare
+ (mf/use-fn
+ (mf/deps entry)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (modal/show! :branch-compare {:branch entry})))
+
+ on-update
+ (mf/use-fn
+ (mf/deps entry)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (confirm-update! entry)))
+
+ on-open-menu
+ (mf/use-fn (mf/deps on-set-menu)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu true)))
+ on-close-menu (mf/use-fn (mf/deps on-set-menu) #(on-set-menu false))
+
+ on-info
+ (mf/use-fn
+ (mf/deps entry)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (modal/show! :branch-info {:branch entry})))
+
+ on-start-rename
+ (mf/use-fn (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (reset! editing? true)))
+
+ on-request-review
+ (mf/use-fn
+ (mf/deps entry)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (modal/show! :create-pull-request {:branch entry})))
+
+ on-open-review
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (st/emit! (dwpr/open-pull-request pr))))
+
+ on-review-details
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (modal/show! :pull-request-info {:pr pr})))
+
+ on-publish-review
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (st/emit! (dwpr/update-pull-request-snapshot (:id pr)))))
+
+ on-merge-review
+ (mf/use-fn
+ (mf/deps entry)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (confirm-merge! entry)))
+
+ on-cancel-review
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (prui/confirm-close! pr)))
+
+ on-rename-commit
+ (mf/use-fn
+ (mf/deps entry)
+ (fn [event]
+ (let [value (str/trim (dom/get-target-val event))]
+ (when (and (seq value) (not= value (:name entry)))
+ (st/emit! (dwb/rename-branch (:id entry) value)))
+ (reset! editing? false))))
+
+ on-rename-key-down
+ (mf/use-fn
+ (mf/deps on-rename-commit)
+ (fn [event]
+ (cond
+ (kbd/enter? event) (on-rename-commit event)
+ (kbd/esc? event) (reset! editing? false))))
+
+ on-archive
+ (mf/use-fn (mf/deps entry archived?)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (st/emit! (dwb/archive-branch (:id entry) (not archived?)))))
+
+ on-delete
+ (mf/use-fn
+ (mf/deps entry)
+ (fn [event]
+ (dom/stop-propagation event)
+ (on-set-menu false)
+ (st/emit! (modal/show {:type :confirm
+ :title (tr "workspace.branches.delete.title")
+ :message (tr "workspace.branches.delete.message" (:name entry))
+ :accept-label (tr "labels.delete")
+ :accept-style :danger
+ :on-accept (fn [_] (st/emit! (dwb/delete-branch (:id entry))))}))))]
+
+ [:li {:class (stl/css-case :branch-entry true
+ :is-archived archived?
+ :is-current current
+ :is-menu-open menu-open?)
+ :role "button"
+ :on-click (when-not current on-open)}
+ [:div {:class (stl/css :branch-entry-icon)}
+ [:> i/icon* {:icon-id (if main? i/git-commit-vertical i/git-branch)}]]
+
+ [:div {:class (stl/css :branch-entry-body)}
+ (if (deref editing?)
+ [:input {:class (stl/css :branch-rename-input)
+ :default-value (:name entry)
+ :auto-focus true
+ :on-click (fn [e] (dom/stop-propagation e))
+ :on-blur on-rename-commit
+ :on-key-down on-rename-key-down}]
+ [:span {:class (stl/css :branch-entry-name)} (:name entry)])
+ [:div {:class (stl/css :branch-entry-meta)}
+ ;; Main is the default branch: always show it the same way (no avatar,
+ ;; just the "Default branch" subtitle), even when it's the current branch
+ (when (and author (not main?))
+ [:> avatar* {:profile author :variant "S"}])
+ [:span {:class (stl/css :branch-entry-author)}
+ (if main?
+ (tr "workspace.branches.main-subtitle")
+ (:fullname author))]]
+ (when (some? pr)
+ [:div {:class (stl/css :branch-entry-badges)}
+ [:> i/icon* {:icon-id i/git-pull-request-arrow :size "s"}]
+ [:> pr-state-badge* {:pr pr}]
+ (when (pos? total)
+ [:span {:class (stl/css :branch-pr-approvals)
+ :title (tr "workspace.pull-requests.info.approvals")}
+ [:> i/icon* {:icon-id i/tick :size "s"}]
+ (dm/str approvals "/" total)])])]
+
+ [:div {:class (stl/css :branch-entry-aside)}
+ (when (and (not archived?) (not main?))
+ [:div {:class (stl/css :branch-entry-counts)}
+ [:span {:class (stl/css :count-ahead)}
+ [:> i/icon* {:icon-id i/arrow-up :size "s"}]
+ (dm/str ahead)]
+ [:span {:class (stl/css :count-behind)}
+ [:> i/icon* {:icon-id i/arrow-down :size "s"}]
+ (dm/str behind)]])
+
+ (when-not main?
+ [:> icon-button* {:variant "ghost"
+ :icon i/menu
+ :aria-label (tr "labels.options")
+ :on-click on-open-menu}])
+
+ [:> dropdown-menu* {:show (and (not main?) menu-open?)
+ :on-close on-close-menu
+ :class (stl/css :branch-options-dropdown)}
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-info}
+ (tr "workspace.branches.menu.info")]
+ (when-not archived?
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-compare}
+ (tr "workspace.branches.compare")])
+ (when (and (not archived?) (pos? behind))
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-update}
+ (tr "workspace.branches.update")])
+ ;; review lifecycle: a branch without an open pull request offers to
+ ;; request one; with it, the review actions take its place (the
+ ;; review either ends merging the branch or is cancelled, leaving
+ ;; the branch as a normal branch again)
+ (when (and (not archived?) (dwpr/enabled?) (nil? pr))
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-request-review}
+ (tr "workspace.pull-requests.menu.request-review")])
+ (when (some? pr)
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-open-review}
+ (tr "workspace.pull-requests.actions.open-review")])
+ (when (some? pr)
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-review-details}
+ (tr "workspace.pull-requests.menu.review-details")])
+ (when (and (some? pr) author?)
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-publish-review}
+ (tr "workspace.pull-requests.menu.publish")])
+ (when (and (some? pr) can-merge?)
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-merge-review}
+ (tr "workspace.pull-requests.actions.merge")])
+ (when (and (some? pr) (or author? admin?))
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-cancel-review}
+ (tr "workspace.pull-requests.actions.close")])
+ (when-not archived?
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-start-rename}
+ (tr "labels.rename")])
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-archive}
+ (tr (if archived?
+ "workspace.branches.menu.restore"
+ "workspace.branches.menu.archive"))]
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-delete}
+ (tr "labels.delete")]]]]))
+
+;; --- Branches panel
+
+(mf/defc branches-toolbox*
+ []
+ (let [profiles (mf/deref refs/profiles)
+ profile (mf/deref refs/profile)
+ file (mf/deref refs/file)
+ branch-ctx (mf/deref branch-context)
+
+ {:keys [status data] :as _state}
+ (mf/deref branches)
+
+ ;; a pull request is a state OF its branch: index the open ones by
+ ;; branch id so each entry can surface its review state inline
+ prs-by-branch
+ (when (dwpr/enabled?)
+ (->> (:data (mf/deref refs/pull-requests))
+ (filter #(= "open" (:status %)))
+ (map (juxt :file-branch-id identity))
+ (into {})))
+
+ ;; make sure the current user resolves as an author (e.g. for the
+ ;; synthetic Main entry and branches the user created)
+ profiles (cond-> profiles
+ (:id profile) (assoc (:id profile) profile))
+
+ ;; the branch (or main) the open file currently is
+ current-entry
+ (if branch-ctx
+ branch-ctx
+ {:name (tr "workspace.branches.main")
+ :created-by (:id profile)
+ :is-main true
+ :status "open"
+ :branch-file-id (:id file)})
+
+ filter* (mf/use-state "")
+ filter-v (deref filter*)
+
+ show-archived? (mf/use-state false)
+
+ ;; only one entry's options dropdown may be open at a time; the open
+ ;; entry's key lives here (in the parent) so opening one closes any
+ ;; other. nil = none open.
+ open-menu* (mf/use-state nil)
+ open-menu (deref open-menu*)
+ entry-key (fn [entry] (dm/str (or (:id entry) (:branch-file-id entry))))
+ set-menu (mf/use-fn (fn [k open?] (reset! open-menu* (when open? k))))
+
+ entries
+ (mf/with-memo [data filter-v]
+ (->> data
+ (filter #(or (str/blank? filter-v)
+ (str/includes? (str/lower (or (:name %) ""))
+ (str/lower filter-v))))))
+
+ ;; the current branch (if any) is shown in its own section; drop it
+ ;; from the rest of the list
+ open-entries (->> entries
+ (filter #(= "open" (:status %)))
+ (remove #(= (:id %) (:id branch-ctx))))
+ archived-entries (->> entries
+ (remove #(= "open" (:status %)))
+ (remove #(= (:id %) (:id branch-ctx))))
+
+ ;; when the open file is a branch, offer Main as a switch target
+ main-entry (when branch-ctx
+ {:name (tr "workspace.branches.main")
+ :is-main true
+ :status "open"
+ :branch-file-id (:source-file-id branch-ctx)})
+
+ other-entries (cond->> open-entries
+ main-entry (cons main-entry))
+
+ on-filter-change
+ (mf/use-fn #(reset! filter* (dom/get-target-val %)))
+
+ on-toggle-archived
+ (mf/use-fn #(swap! show-archived? not))
+
+ on-create
+ (mf/use-fn
+ (mf/deps file)
+ (fn [_]
+ (modal/show! :create-branch {:file-name (:name file)})))]
+
+ (mf/with-effect []
+ (st/emit! (dwb/init-branches-state))
+ (when (dwpr/enabled?)
+ (st/emit! (dwpr/init-pull-requests-state))))
+
+ [:div {:class (stl/css :branches-toolbox)}
+ [:div {:class (stl/css :branches-header)}
+ [:> input* {:variant "dense"
+ :icon i/search
+ :placeholder (tr "workspace.branches.search.placeholder")
+ :value filter-v
+ :on-change on-filter-change}]
+ [:> button* {:variant "primary"
+ :icon i/git-branch-plus
+ :on-click on-create}
+ (tr "workspace.branches.new")]]
+
+ (cond
+ (= status :loading)
+ [:div {:class (stl/css :branches-empty)}
+ [:> empty-state* {:icon i/git-branch
+ :text (tr "workspace.branches.loading")}]]
+
+ :else
+ [:div {:class (stl/css :branches-list)}
+ ;; current branch (or main) the open file currently is
+ [:div {:class (stl/css :branches-section-header)}
+ [:span (tr "workspace.branches.section.current")]]
+ [:ul {:class (stl/css :branches-entries)}
+ (let [k (entry-key current-entry)]
+ [:> branch-entry* {:entry current-entry
+ :profiles profiles
+ :current true
+ :pr (get prs-by-branch (:id current-entry))
+ :menu-open? (= open-menu k)
+ :on-set-menu (partial set-menu k)}])]
+
+ (when (seq other-entries)
+ [:div {:class (stl/css :branches-section-header)}
+ [:span (tr "workspace.branches.section.others")]
+ [:span {:class (stl/css :branches-section-count)} (dm/str (count other-entries))]])
+ (when (seq other-entries)
+ [:ul {:class (stl/css :branches-entries)}
+ (for [entry other-entries]
+ (let [k (entry-key entry)]
+ [:> branch-entry* {:key k
+ :entry entry
+ :profiles profiles
+ :pr (get prs-by-branch (:id entry))
+ :menu-open? (= open-menu k)
+ :on-set-menu (partial set-menu k)}]))])
+
+ (when (seq archived-entries)
+ [:div {:class (stl/css :branches-archived)}
+ [:button {:class (stl/css :branches-archived-toggle)
+ :on-click on-toggle-archived}
+ [:> i/icon* {:icon-id (if (deref show-archived?) i/arrow-down i/arrow-right) :size "s"}]
+ [:span (tr "workspace.branches.archived")]
+ [:span {:class (stl/css :branches-archived-count)} (dm/str (count archived-entries))]]
+ (when (deref show-archived?)
+ [:ul {:class (stl/css :branches-entries)}
+ (for [entry archived-entries]
+ (let [k (entry-key entry)]
+ [:> branch-entry* {:key k
+ :entry entry
+ :profiles profiles
+ :menu-open? (= open-menu k)
+ :on-set-menu (partial set-menu k)}]))])])])]))
+
+;; --- Compare changes dialog (read-only 3-way diff)
+
+(mf/defc compare-value-chip*
+ {::mf/private true}
+ [{:keys [value tone]}]
+ (let [hex (hex-color value)]
+ [:span {:class (stl/css-case :value-chip true
+ :value-main (= tone :main)
+ :value-branch (= tone :branch)
+ :value-base (= tone :base))}
+ (when hex
+ [:span {:class (stl/css :value-swatch)
+ :style {:background-color hex}}])
+ (if (and hex (not (sequential? value)))
+ hex
+ (display-val value))]))
+
+(mf/defc branch-compare-item*
+ {::mf/private true}
+ [{:keys [item index selected on-select]}]
+ (let [on-click (mf/use-fn (mf/deps index on-select) #(on-select index))
+ status (item-status item)
+ attrs (:changed-attrs item)
+ type-lbl (type-label-key item)
+ icon-id (if (= :shape (:kind item))
+ (shape-icon-id item)
+ (get kind->icon (:kind item) i/git-branch))
+ subtitle (cond
+ (= :added status) (tr "workspace.branches.compare.subtitle-new"
+ (if type-lbl (tr type-lbl) ""))
+ (and (= :modified status) (seq attrs))
+ (tr "workspace.branches.compare.subtitle-mods" (count attrs))
+ type-lbl (tr type-lbl)
+ :else nil)]
+ [:li {:class (stl/css-case :compare-item true
+ :is-selected (= index selected)
+ :status-added (= :added status)
+ :status-modified (= :modified status)
+ :status-deleted (= :deleted status)
+ :status-conflict (= :conflict status))
+ :role "button"
+ :on-click on-click}
+ [:div {:class (stl/css :compare-item-icon)}
+ [:> i/icon* {:icon-id icon-id}]]
+ [:div {:class (stl/css :compare-item-body)}
+ [:span {:class (stl/css :compare-item-label)} (:label item)]
+ (when subtitle
+ [:span {:class (stl/css :compare-item-subtitle)} subtitle])]
+ [:span {:class (stl/css-case :item-badge true
+ :badge-added (= :added status)
+ :badge-modified (= :modified status)
+ :badge-deleted (= :deleted status)
+ :badge-conflict (= :conflict status))}
+ (tr (get status->label status "workspace.branches.status.modified"))]]))
+
+(mf/defc branch-compare-detail*
+ {::mf/private true}
+ [{:keys [item]}]
+ (let [attrs (:changed-attrs item)
+ type-lbl (type-label-key item)]
+ (cond
+ (nil? item)
+ [:div {:class (stl/css :compare-detail-empty)}
+ [:> empty-state* {:icon i/switch
+ :text (tr "workspace.branches.compare.select-hint")}]]
+
+ :else
+ [:div {:class (stl/css :compare-detail-content)}
+ [:div {:class (stl/css :compare-detail-head)}
+ [:h3 {:class (stl/css :compare-detail-title)} (:label item)]
+ (when type-lbl
+ [:span {:class (stl/css :compare-detail-type)} (tr type-lbl)])]
+
+ (if (seq attrs)
+ [:div {:class (stl/css :prop-changes)}
+ [:div {:class (stl/css :prop-changes-head)}
+ [:span (tr "workspace.branches.compare.prop-changes")]
+ [:span {:class (stl/css :prop-changes-count)} (dm/str (count attrs))]]
+ (for [[attr {:keys [main branch]}] attrs]
+ [:div {:class (stl/css :prop-row) :key (str attr)}
+ [:span {:class (stl/css :prop-name)} (attr-label attr)]
+ [:div {:class (stl/css :prop-values)}
+ [:> compare-value-chip* {:value main :tone :main}]
+ [:> i/icon* {:icon-id i/arrow-long-right :size "s"}]
+ [:> compare-value-chip* {:value branch :tone :branch}]]])]
+ [:p {:class (stl/css :compare-detail-hint)}
+ (tr "workspace.branches.compare.no-props")])])))
+
+(mf/defc branch-compare-dialog*
+ {::mf/register modal/components
+ ::mf/register-as :branch-compare}
+ [{:keys [branch]}]
+ (let [{:keys [status diff selected direction]} (mf/deref branch-diff)
+ direction (or direction :branch->main)
+ incoming? (= direction :main->branch)
+
+ items
+ (mf/with-memo [diff]
+ (into (vec (:changes diff)) (:conflicts diff)))
+
+ stats (:stats diff)
+ sel-item (when (and (some? selected) (< selected (count items)))
+ (nth items selected))
+
+ active-filter* (mf/use-state :all)
+ active-filter (deref active-filter*)
+
+ ;; keep the original index for selection while filtering/grouping
+ indexed (map-indexed vector items)
+ filtered (filterv (fn [[_ it]] (status-matches? active-filter (item-status it))) indexed)
+ by-cat (group-by (fn [[_ it]] (get kind->category (:kind it) :pages)) filtered)
+
+ on-close (mf/use-fn #(st/emit! (modal/hide)))
+ on-select (mf/use-fn #(st/emit! (dwb/select-diff-change %)))
+ on-filter (mf/use-fn (fn [f] (reset! active-filter* f)))
+ on-swap (mf/use-fn (mf/deps branch direction)
+ #(st/emit! (dwb/fetch-branch-diff
+ (:id branch)
+ (if incoming? :branch->main :main->branch))))
+ on-merge (mf/use-fn (mf/deps branch)
+ #(confirm-merge! branch))
+ on-update (mf/use-fn (mf/deps branch)
+ #(confirm-update! branch))
+ on-resolve (mf/use-fn (mf/deps branch direction)
+ #(modal/show! :branch-conflicts
+ {:branch branch
+ :mode (if incoming? :update :merge)}))
+ on-export (mf/use-fn
+ (mf/deps diff branch)
+ (fn []
+ (let [payload (clj->js {:stats stats
+ :changes (:changes diff)
+ :conflicts (:conflicts diff)})
+ text (js/JSON.stringify payload nil 2)
+ blob (js/Blob. #js [text] #js {:type "application/json"})
+ url (js/URL.createObjectURL blob)
+ a (.createElement js/document "a")]
+ (set! (.-href a) url)
+ (set! (.-download a) (dm/str (:name branch) "-diff.json"))
+ (.click a)
+ (js/URL.revokeObjectURL url))))]
+
+ (mf/with-effect [(:id branch)]
+ (st/emit! (dwb/fetch-branch-diff (:id branch))))
+
+ ;; auto-select the first change once loaded
+ (mf/with-effect [status (count items)]
+ (when (and (= status :loaded) (nil? selected) (seq items))
+ (st/emit! (dwb/select-diff-change 0))))
+
+ [:div {:class (stl/css :compare-overlay)}
+ [:div {:class (stl/css :compare-container)}
+ [:div {:class (stl/css :compare-header)}
+ [:div {:class (stl/css :compare-title-group)}
+ [:div {:class (stl/css :compare-title-icon)}
+ [:> i/icon* {:icon-id i/switch}]]
+ [:div {:class (stl/css :compare-title-text)}
+ [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.compare.title")]
+ [:div {:class (stl/css :compare-breadcrumb)}
+ (if incoming?
+ [:*
+ [:span {:class (stl/css :breadcrumb-main)}
+ [:> i/icon* {:icon-id i/git-commit-vertical :size "s"}]
+ (tr "workspace.branches.main")]
+ [:button {:class (stl/css :breadcrumb-swap)
+ :title (tr "workspace.branches.compare.swap")
+ :aria-label (tr "workspace.branches.compare.swap")
+ :on-click on-swap}
+ [:> i/icon* {:icon-id i/arrow-long-right :size "s"}]]
+ [:span {:class (stl/css :breadcrumb-branch)}
+ [:> i/icon* {:icon-id i/git-branch :size "s"}]
+ (:name branch)]]
+ [:*
+ [:span {:class (stl/css :breadcrumb-branch)}
+ [:> i/icon* {:icon-id i/git-branch :size "s"}]
+ (:name branch)]
+ [:button {:class (stl/css :breadcrumb-swap)
+ :title (tr "workspace.branches.compare.swap")
+ :aria-label (tr "workspace.branches.compare.swap")
+ :on-click on-swap}
+ [:> i/icon* {:icon-id i/arrow-long-right :size "s"}]]
+ [:span {:class (stl/css :breadcrumb-main)}
+ [:> i/icon* {:icon-id i/git-commit-vertical :size "s"}]
+ (tr "workspace.branches.main")]])]]]
+
+ (when stats
+ [:div {:class (stl/css :compare-stats)}
+ [:span {:class (stl/css :stat-badge :badge-added)}
+ (dm/str (:added stats) " " (tr "workspace.branches.compare.stats.added"))]
+ [:span {:class (stl/css :stat-badge :badge-modified)}
+ (dm/str (:modified stats) " " (tr "workspace.branches.compare.stats.modified"))]
+ [:span {:class (stl/css :stat-badge :badge-deleted)}
+ (dm/str (:deleted stats) " " (tr "workspace.branches.compare.stats.deleted"))]
+ (when (pos? (:conflicts stats))
+ [:span {:class (stl/css :stat-badge :badge-conflict)}
+ (dm/str (:conflicts stats) " " (tr "workspace.branches.compare.stats.conflicts"))])])
+
+ [:> icon-button* {:variant "ghost"
+ :icon i/close
+ :aria-label (tr "labels.close")
+ :on-click on-close}]]
+
+ (cond
+ (= status :loading)
+ [:div {:class (stl/css :compare-empty)}
+ [:> empty-state* {:icon i/switch
+ :text (tr "workspace.branches.compare.loading")}]]
+
+ (= status :error)
+ [:div {:class (stl/css :compare-empty)}
+ [:> empty-state* {:icon i/triangle-alert
+ :text (tr "workspace.branches.compare.error")}]]
+
+ (empty? items)
+ [:div {:class (stl/css :compare-empty)}
+ [:> empty-state* {:icon i/git-branch
+ :text (tr "workspace.branches.compare.empty")}]]
+
+ :else
+ [:div {:class (stl/css :compare-body)}
+ [:div {:class (stl/css :compare-side)}
+ [:div {:class (stl/css :compare-filters)}
+ (for [[f label] compare-filters]
+ [:button {:key (name f)
+ :class (stl/css-case :filter-chip true
+ :is-active (= active-filter f)
+ :dot-added (= f :added)
+ :dot-modified (= f :modified)
+ :dot-deleted (= f :deleted))
+ :on-click #(on-filter f)}
+ (when-not (= f :all) [:span {:class (stl/css :filter-dot)}])
+ (tr label)])]
+
+ [:div {:class (stl/css :compare-list)}
+ (for [cat category-order
+ :let [group (get by-cat cat)]
+ :when (seq group)]
+ [:div {:class (stl/css :compare-group) :key (name cat)}
+ [:div {:class (stl/css :compare-group-head)}
+ [:> i/icon* {:icon-id (get category->icon cat i/document) :size "s"}]
+ [:span {:class (stl/css :compare-group-label)} (tr (get category->label cat))]
+ (let [freqs (frequencies (map (fn [[_ it]] (item-status it)) group))]
+ [:span {:class (stl/css :compare-group-counts)}
+ (when (pos? (get freqs :added 0))
+ [:span {:class (stl/css :count-added)} (dm/str "+" (get freqs :added))])
+ (when (pos? (+ (get freqs :modified 0) (get freqs :conflict 0)))
+ [:span {:class (stl/css :count-modified)}
+ (dm/str "~" (+ (get freqs :modified 0) (get freqs :conflict 0)))])
+ (when (pos? (get freqs :deleted 0))
+ [:span {:class (stl/css :count-deleted)} (dm/str "−" (get freqs :deleted))])])]
+ [:ul {:class (stl/css :compare-group-items)}
+ (for [[idx item] group]
+ [:> branch-compare-item* {:key idx
+ :item item
+ :index idx
+ :selected selected
+ :on-select on-select}])]])]]
+
+ [:div {:class (stl/css :compare-detail)}
+ [:> branch-compare-detail* {:item sel-item}]]])
+
+ (when (= status :loaded)
+ (let [conflicts (long (or (:conflicts stats) 0))
+ conflicts? (pos? conflicts)
+ total (long (+ (or (:added stats) 0)
+ (or (:modified stats) 0)
+ (or (:deleted stats) 0)))]
+ [:div {:class (stl/css :compare-footer)}
+ [:div {:class (stl/css :compare-footer-info)}
+ [:> i/icon* {:icon-id i/info :size "s"}]
+ [:span (tr "workspace.branches.compare.footer-changes" total)]
+ (when conflicts?
+ [:span {:class (stl/css :compare-footer-conflicts)}
+ (dm/str " · " (tr "workspace.branches.compare.footer-conflicts" conflicts))])]
+ [:div {:class (stl/css :compare-footer-actions)}
+ [:> button* {:variant "secondary"
+ :icon i/download
+ :on-click on-export}
+ (tr "workspace.branches.compare.export")]
+ ;; one action that follows the current direction: update from main
+ ;; while viewing main's incoming changes, merge otherwise
+ (cond
+ conflicts?
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :on-click on-resolve}
+ (tr "workspace.branches.conflicts.resolve")]
+
+ incoming?
+ [:> button* {:variant "primary"
+ :icon i/status-update
+ :disabled (zero? total)
+ :on-click on-update}
+ (tr "workspace.branches.update")]
+
+ :else
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :disabled (zero? total)
+ :on-click on-merge}
+ (tr "workspace.branches.merge.action")])]]))]]))
+
+;; --- Resolve conflicts dialog
+
+(def ^:private conflict-reason->label
+ {:modify-modify "workspace.branches.conflicts.reason.modify-modify"
+ :add-add "workspace.branches.conflicts.reason.add-add"
+ :delete-modify "workspace.branches.conflicts.reason.delete-modify"
+ :modify-delete "workspace.branches.conflicts.reason.modify-delete"})
+
+(mf/defc branch-conflict-item*
+ {::mf/private true}
+ [{:keys [conflict index selected resolution on-select]}]
+ (let [on-click (mf/use-fn (mf/deps index on-select) #(on-select index))
+ resolved? (bm/conflict-resolved? conflict resolution)
+ mixed? (map? resolution)
+ icon-id (if (= :shape (:kind conflict))
+ (shape-icon-id conflict)
+ (get kind->icon (:kind conflict) i/git-branch))
+ type-lbl (type-label-key conflict)]
+ [:li {:class (stl/css-case :conflict-item true
+ :is-selected (= index selected)
+ :is-pending (not resolved?))
+ :role "button"
+ :on-click on-click}
+ [:div {:class (stl/css :conflict-item-icon)}
+ [:> i/icon* {:icon-id icon-id}]]
+ [:div {:class (stl/css :conflict-item-body)}
+ [:span {:class (stl/css :conflict-item-label)} (:label conflict)]
+ [:span {:class (stl/css :conflict-item-subtitle)}
+ (cond-> ""
+ type-lbl (str (tr type-lbl) " · ")
+ :always (str (tr (if resolved?
+ "workspace.branches.conflicts.state-resolved"
+ "workspace.branches.conflicts.state-pending"))))]]
+ (cond
+ (not resolved?)
+ [:span {:class (stl/css :conflict-pill :pill-pending)}
+ [:> i/icon* {:icon-id i/triangle-alert :size "s"}]]
+ mixed?
+ [:span {:class (stl/css :conflict-pill :pill-mixed)}
+ [:span {:class (stl/css :pill-dot)}]
+ (tr "workspace.branches.conflicts.side-mixed")]
+ (= resolution :main)
+ [:span {:class (stl/css :conflict-pill :pill-main)}
+ [:span {:class (stl/css :pill-dot)}]
+ (tr "workspace.branches.conflicts.side-main")]
+ :else
+ [:span {:class (stl/css :conflict-pill :pill-branch)}
+ [:span {:class (stl/css :pill-dot)}]
+ (tr "workspace.branches.conflicts.side-branch")])]))
+
+;; --- Per-element visual previews ---
+
+(def ^:private renderable-shape-types
+ "Shape types we can faithfully render to SVG from a standalone shape map.
+ Images / svg-raw reference (possibly cross-file) media and fall back to a
+ type icon instead."
+ #{:rect :circle :frame :path :bool :text :group})
+
+(def ^:private geometry-attrs
+ "Attrs whose change invalidates the cached `:selrect`/`:points`; when any
+ of them is taken from the branch in a per-attr resolution, the result
+ preview must adopt the branch's geometry so the framing stays correct."
+ #{:x :y :width :height :rotation :transform :transform-inverse :flip-x :flip-y})
+
+(defn- shape-bounds
+ "Axis-aligned bounds framing a shape, taken from its rotated corner
+ `:points` when present, else its `:selrect`."
+ [shape]
+ (let [pts (:points shape)]
+ (if (and (sequential? pts) (seq pts))
+ (let [xs (keep :x pts) ys (keep :y pts)
+ x (apply min xs) y (apply min ys)]
+ {:x x :y y :width (- (apply max xs) x) :height (- (apply max ys) y)})
+ (let [sr (:selrect shape)]
+ {:x (:x sr 0) :y (:y sr 0) :width (:width sr 1) :height (:height sr 1)}))))
+
+(mf/defc shape-preview*
+ "Renders a single shape map to a small, fitted SVG using the real shape
+ renderers (so fills, gradients, strokes, corner radii, opacity and text
+ are accurate), without needing any workspace/objects context."
+ {::mf/private true}
+ [{:keys [shape]}]
+ (let [{:keys [x y width height]} (shape-bounds shape)
+ width (max 1 width)
+ height (max 1 height)
+ objects (mf/with-memo [shape] {(:id shape) shape})
+ wrapper (mf/with-memo [objects] (render/shape-wrapper-factory objects))]
+ [:svg {:class (stl/css :preview-svg)
+ :viewBox (dm/str x " " y " " width " " height)
+ :preserveAspectRatio "xMidYMid meet"
+ :xmlns "http://www.w3.org/2000/svg"
+ :fill "none"}
+ [:> wrapper {:shape shape}]]))
+
+(mf/defc entity-preview*
+ "Visual preview of one side of a conflict, dispatching on entity kind:
+ shapes render to SVG, library colors to a swatch, typographies to a text
+ sample; everything else (and a nil/deleted side) shows a placeholder."
+ {::mf/private true}
+ [{:keys [kind value]}]
+ (cond
+ (nil? value)
+ [:div {:class (stl/css :preview-empty)}
+ [:> i/icon* {:icon-id i/delete :size "s"}]]
+
+ (= kind :shape)
+ (if (contains? renderable-shape-types (:type value))
+ [:> shape-preview* {:shape value}]
+ [:div {:class (stl/css :preview-empty)}
+ [:> i/icon* {:icon-id (shape-icon-id value)}]])
+
+ (= kind :color)
+ [:div {:class (stl/css :preview-swatch)
+ :style {:background (or (uc/color->background value)
+ (hex-color value)
+ "transparent")}}]
+
+ (= kind :typography)
+ [:div {:class (stl/css :preview-type-sample)
+ :style {:font-family (:font-family value)
+ :font-weight (:font-weight value)
+ :font-style (:font-style value)}}
+ "Ag"]
+
+ :else
+ [:div {:class (stl/css :preview-empty)}
+ [:> i/icon* {:icon-id (get kind->icon kind i/git-branch)}]]))
+
+(defn- merged-entity
+ "Synthesize the resulting entity for the RESULT preview from the `main`
+ and `branch` entities, the conflict's changed-attr keys and the current
+ `res` (a `:main`/`:branch` keyword or a per-attr `{attr -> side}` map)."
+ [main branch attrs res]
+ (cond
+ (= res :branch) branch
+ (or (nil? res) (= res :main)) main
+ (map? res)
+ (let [merged (reduce (fn [acc k]
+ (if (= :branch (get res k))
+ (assoc acc k (get branch k))
+ acc))
+ main attrs)]
+ (if (and (map? main) (map? branch)
+ (some (fn [k] (and (contains? geometry-attrs k) (= :branch (get res k)))) attrs))
+ (assoc merged
+ :selrect (:selrect branch)
+ :points (:points branch)
+ :transform (:transform branch)
+ :transform-inverse (:transform-inverse branch))
+ merged))
+ :else main))
+
+(defn- attr-choice
+ "Currently-selected side (`:main`/`:branch`) for `attr` under resolution
+ `res`, or nil when undecided (so the property row shows no selection until
+ the user acts)."
+ [res attr]
+ (cond
+ (= res :branch) :branch
+ (= res :main) :main
+ (map? res) (get res attr)
+ :else nil))
+
+(mf/defc conflict-preview-row*
+ "The BASE · MAIN · BRANCH · RESULT preview strip on top of the detail
+ panel. RESULT updates live as per-property choices change."
+ {::mf/private true}
+ [{:keys [conflict resolution base-sub main-sub branch-sub]}]
+ (let [kind (:kind conflict)
+ attrs (keys (:changed-attrs conflict))
+ result (merged-entity (:main conflict) (:branch conflict) attrs resolution)
+ cols [[:base "workspace.branches.conflicts.card.base" base-sub (:base conflict)]
+ [:main "workspace.branches.conflicts.card.main" main-sub (:main conflict)]
+ [:branch "workspace.branches.conflicts.card.branch" branch-sub (:branch conflict)]
+ [:result "workspace.branches.conflicts.card.result" nil result]]]
+ [:div {:class (stl/css :preview-row)}
+ (for [[col-key label sub value] cols]
+ [:div {:class (stl/css-case :preview-col true :is-result (= col-key :result))
+ :key (name col-key)}
+ [:span {:class (stl/css :preview-col-label)} (tr label)]
+ [:div {:class (stl/css :preview-box)}
+ [:> entity-preview* {:kind kind :value value}]]
+ (when sub [:span {:class (stl/css :preview-col-sub)} sub])])]))
+
+(mf/defc conflict-prop-row*
+ "One property of a conflict: its label and two clickable value chips
+ (MAIN / BRANCH). Clicking a chip keeps that side for this property only."
+ {::mf/private true}
+ [{:keys [id attr main branch choice attrs]}]
+ (let [on-main (mf/use-fn (mf/deps id attr attrs)
+ #(st/emit! (dwb/set-conflict-attr-resolution id attr :main attrs)))
+ on-branch (mf/use-fn (mf/deps id attr attrs)
+ #(st/emit! (dwb/set-conflict-attr-resolution id attr :branch attrs)))]
+ [:div {:class (stl/css :prop-pick-row)}
+ [:span {:class (stl/css :prop-pick-name)} (attr-label attr)]
+ [:button {:class (stl/css-case :prop-pick true :is-chosen (= choice :main))
+ :on-click on-main}
+ [:> compare-value-chip* {:value main :tone :main}]]
+ [:button {:class (stl/css-case :prop-pick true :is-chosen (= choice :branch))
+ :on-click on-branch}
+ [:> compare-value-chip* {:value branch :tone :branch}]]]))
+
+(mf/defc branch-conflicts-dialog*
+ {::mf/register modal/components
+ ::mf/register-as :branch-conflicts}
+ [{:keys [branch mode]}]
+ (let [{:keys [diff selected resolutions status]} (mf/deref branch-diff)
+
+ loading? (= status :loading)
+
+ ;; merge mode only: whether to keep the branch archived after merge
+ keep?* (mf/use-state false)
+ keep? (deref keep?*)
+ on-toggle-keep (mf/use-fn #(swap! keep?* not))
+
+ diff-meta (:meta diff)
+ ;; per-side header subtitle: base is pinned at branch creation, main
+ ;; and branch show how recent each one is ("3 hours ago"); the branch
+ ;; also names the author as "you" (it's always the current user).
+ base-sub (tr "workspace.branches.conflicts.card.base-meta")
+ main-sub (some-> (:main-at diff-meta) ct/inst ct/timeago)
+ branch-sub (let [ago (some-> (:branch-at diff-meta) ct/inst ct/timeago)
+ you (tr "workspace.branches.conflicts.card.you")]
+ (if ago (str ago " · " you) you))
+
+ conflicts (:conflicts diff)
+ resolutions (or resolutions {})
+ total (count conflicts)
+ resolved (count (filterv #(bm/conflict-resolved? % (get resolutions (:id %))) conflicts))
+ pending (- total resolved)
+ all-done? (and (pos? total) (zero? pending))
+
+ sel-idx (min (or selected 0) (max 0 (dec total)))
+ sel (when (seq conflicts) (nth conflicts sel-idx))
+ sel-res (when sel (get resolutions (:id sel)))
+ sel-type (when sel (type-label-key sel))
+ sel-reason (when sel (get conflict-reason->label (:reason sel)))
+ sel-icon (when sel (if (= :shape (:kind sel))
+ (shape-icon-id sel)
+ (get kind->icon (:kind sel) i/git-branch)))
+
+ on-close (mf/use-fn #(st/emit! (modal/hide)))
+ on-select (mf/use-fn #(st/emit! (dwb/select-diff-change %)))
+ on-all-main (mf/use-fn #(st/emit! (dwb/set-all-resolutions :main)))
+ on-all-branch (mf/use-fn #(st/emit! (dwb/set-all-resolutions :branch)))
+ on-use-main (mf/use-fn (mf/deps sel)
+ #(when sel (st/emit! (dwb/set-conflict-resolution (:id sel) :main))))
+ on-use-branch (mf/use-fn (mf/deps sel)
+ #(when sel (st/emit! (dwb/set-conflict-resolution (:id sel) :branch))))
+ on-apply (mf/use-fn (mf/deps branch resolutions mode keep?)
+ #(st/emit! (if (= mode :update)
+ (dwb/update-branch-from-main branch resolutions)
+ (dwb/merge-branch branch {:resolutions resolutions
+ :keep-branch keep?}))))]
+
+ (mf/with-effect [(:id branch)]
+ (st/emit! (dwb/fetch-branch-diff (:id branch))))
+
+ [:div {:class (stl/css :compare-overlay)}
+ [:div {:class (stl/css :compare-container)}
+ [:div {:class (stl/css :compare-header)}
+ [:div {:class (stl/css :compare-title-group)}
+ [:div {:class (stl/css :compare-title-icon :title-icon-warning)}
+ [:> i/icon* {:icon-id i/triangle-alert}]]
+ [:div {:class (stl/css :compare-title-text)}
+ [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.conflicts.title")]
+ [:span {:class (stl/css :compare-subtitle)}
+ (tr "workspace.branches.conflicts.subtitle" (:name branch))]]]
+ [:span {:class (stl/css :conflicts-progress)}
+ (tr "workspace.branches.conflicts.progress" (str resolved) (str total))]
+ [:> icon-button* {:variant "ghost"
+ :icon i/close
+ :aria-label (tr "labels.close")
+ :on-click on-close}]]
+
+ [:div {:class (stl/css :conflicts-toolbar)}
+ [:span {:class (stl/css :bulk-label)} (tr "workspace.branches.conflicts.bulk-label")]
+ [:> button* {:variant "secondary" :icon i/git-branch :on-click on-all-main}
+ (tr "workspace.branches.conflicts.all-main")]
+ [:> button* {:variant "secondary" :icon i/git-branch :on-click on-all-branch}
+ (tr "workspace.branches.conflicts.all-branch")]
+ (when (pos? pending)
+ [:span {:class (stl/css :conflicts-pending)}
+ (tr "workspace.branches.conflicts.pending" (str pending))])]
+
+ (cond
+ loading?
+ [:div {:class (stl/css :compare-empty)}
+ [:> empty-state* {:icon i/git-merge
+ :text (tr "workspace.branches.compare.loading")}]]
+
+ (empty? conflicts)
+ [:div {:class (stl/css :compare-empty)}
+ [:> empty-state* {:icon i/git-merge
+ :text (tr "workspace.branches.conflicts.empty")}]]
+
+ :else
+ [:div {:class (stl/css :compare-body)}
+ [:div {:class (stl/css :conflicts-list-side)}
+ [:span {:class (stl/css :conflicts-list-head)}
+ (tr "workspace.branches.conflicts.list-head" (str total))]
+ [:ul {:class (stl/css :compare-list)}
+ (for [[idx c] (map-indexed vector conflicts)]
+ [:> branch-conflict-item* {:key idx
+ :conflict c
+ :index idx
+ :selected sel-idx
+ :resolution (get resolutions (:id c))
+ :on-select on-select}])]]
+
+ [:div {:class (stl/css :compare-detail)}
+ (when sel
+ (let [sel-attrs (:changed-attrs sel)
+ attr-keys (keys sel-attrs)
+ has-attrs? (seq sel-attrs)]
+ [:*
+ [:div {:class (stl/css :conflict-detail-head)}
+ [:div {:class (stl/css :conflict-detail-titlerow)}
+ [:div {:class (stl/css :conflict-detail-icon)}
+ [:> i/icon* {:icon-id sel-icon}]]
+ [:h3 {:class (stl/css :conflict-detail-title)} (:label sel)]
+ [:div {:class (stl/css :conflict-global-actions)}
+ [:> button* {:variant (if (= sel-res :main) "primary" "secondary")
+ :on-click on-use-main}
+ (tr "workspace.branches.conflicts.use-main")]
+ [:> button* {:variant (if (= sel-res :branch) "primary" "secondary")
+ :on-click on-use-branch}
+ (tr "workspace.branches.conflicts.use-branch")]]]
+ [:span {:class (stl/css :conflict-detail-subtitle)}
+ (cond-> ""
+ sel-type (str (tr sel-type))
+ sel-reason (str " - " (tr sel-reason)))]]
+
+ [:> conflict-preview-row* {:conflict sel
+ :resolution sel-res
+ :base-sub base-sub
+ :main-sub main-sub
+ :branch-sub branch-sub}]
+
+ (if has-attrs?
+ [:div {:class (stl/css :prop-table)}
+ [:div {:class (stl/css :prop-table-head)}
+ [:span {:class (stl/css :prop-table-th)}
+ (tr "workspace.branches.conflicts.prop-header")]
+ [:span {:class (stl/css :prop-table-th)}
+ (tr "workspace.branches.conflicts.card.main")]
+ [:span {:class (stl/css :prop-table-th)}
+ (tr "workspace.branches.conflicts.card.branch")]]
+ (for [[attr {:keys [main branch]}] sel-attrs]
+ [:> conflict-prop-row* {:key (str attr)
+ :id (:id sel)
+ :attr attr
+ :main main
+ :branch branch
+ :choice (attr-choice sel-res attr)
+ :attrs attr-keys}])]
+
+ [:p {:class (stl/css :conflict-whole-hint)}
+ (tr "workspace.branches.conflicts.whole-hint")])]))]])
+
+ [:div {:class (stl/css :compare-footer)}
+ [:div {:class (stl/css :compare-footer-info)}
+ (if all-done?
+ [:*
+ [:> i/icon* {:icon-id i/tick :size "s"}]
+ (tr "workspace.branches.conflicts.footer-ready")]
+ [:*
+ [:span {:class (stl/css :compare-footer-conflicts)}
+ [:> i/icon* {:icon-id i/triangle-alert :size "s"}]]
+ [:span {:class (stl/css :compare-footer-conflicts)}
+ (tr "workspace.branches.conflicts.footer-pending" (str pending))]])]
+ [:div {:class (stl/css :compare-footer-actions)}
+ ;; applying a MERGE deletes the branch unless the user opts to
+ ;; keep it archived — same choice the plain merge dialog offers
+ (when (not= mode :update)
+ [:> checkbox* {:id "conflicts-archive-branch"
+ :label (tr "workspace.branches.merge.archive-label")
+ :checked keep?
+ :on-change on-toggle-keep}])
+ [:> button* {:variant "secondary" :on-click on-close} (tr "labels.cancel")]
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :disabled (not all-done?)
+ :on-click on-apply}
+ (tr "workspace.branches.conflicts.apply")]]]]]))
+
+;; --- Branch context banner (shown while editing a branch)
+
+(mf/defc branch-context-banner*
+ [{:keys [file-id]}]
+ (let [ctx (mf/deref branch-context)
+ merged? (contains? #{"merged" "archived"} (:status ctx))
+
+ collapsed* (mf/use-state false)
+ collapsed? (deref collapsed*)
+ on-toggle (mf/use-fn #(swap! collapsed* not))
+
+ ;; persistence status drives a refetch of the change counts on save
+ persistence (mf/deref refs/persistence)
+ pstatus (:status persistence)
+
+ ;; with pull requests enabled the merge action moves behind the
+ ;; kebab menu and requesting a review becomes the primary action
+ ;; (or opening the branch's already-open pull request)
+ prs-enabled? (dwpr/enabled?)
+ open-pr (when prs-enabled?
+ (->> (:data (mf/deref refs/pull-requests))
+ (filter #(and (= "open" (:status %))
+ (= (:id ctx) (:file-branch-id %))))
+ (first)))
+
+ menu-open* (mf/use-state false)
+ menu-open? (deref menu-open*)
+ on-open-menu (mf/use-fn
+ (fn [event]
+ ;; the dropdown closes itself on any outside
+ ;; click: the opening one must not reach it
+ (dom/stop-propagation event)
+ (reset! menu-open* true)))
+ on-close-menu (mf/use-fn #(reset! menu-open* false))
+
+ on-compare (mf/use-fn (mf/deps ctx) #(modal/show! :branch-compare {:branch ctx}))
+ on-update (mf/use-fn (mf/deps ctx) #(confirm-update! ctx))
+ on-open-main (mf/use-fn (mf/deps ctx) #(st/emit! (dwb/open-branch (:source-file-id ctx))))
+ on-resolve (mf/use-fn (mf/deps ctx) #(modal/show! :branch-conflicts {:branch ctx :mode :merge}))
+
+ on-merge
+ (mf/use-fn
+ (mf/deps ctx)
+ (fn [_]
+ (reset! menu-open* false)
+ (confirm-merge! ctx)))
+
+ on-request-review
+ (mf/use-fn
+ (mf/deps ctx)
+ #(modal/show! :create-pull-request {:branch ctx}))
+
+ on-open-review
+ (mf/use-fn
+ (mf/deps open-pr)
+ #(st/emit! (dwpr/open-pull-request open-pr)))]
+
+ (mf/with-effect [file-id]
+ (when (contains? cf/flags :branching)
+ (st/emit! (dwb/fetch-branch-context)))
+ ;; the primary action depends on whether the branch already has an
+ ;; open pull request
+ (when (dwpr/enabled?)
+ (st/emit! (dwpr/fetch-pull-requests))))
+
+ ;; refresh the change counts every time a save settles (and when
+ ;; returning to the tab, so "main advanced" is surfaced too)
+ (mf/with-effect [pstatus]
+ (when (and (= :saved pstatus) (contains? cf/flags :branching))
+ (st/emit! (dwb/fetch-branch-context))))
+
+ (mf/with-effect []
+ (let [key (events/listen globals/window "focus"
+ (fn [_]
+ (when (contains? cf/flags :branching)
+ (st/emit! (dwb/fetch-branch-context)))))]
+ (fn [] (events/unlistenByKey key))))
+
+ (when ctx
+ [:div {:class (stl/css-case :branch-banner true
+ :branch-banner-merged merged?
+ :is-collapsed collapsed?)}
+ [:button {:class (stl/css :branch-banner-handle)
+ :title (tr (if collapsed?
+ "workspace.branches.banner.expand"
+ "workspace.branches.banner.collapse"))
+ :aria-label (tr (if collapsed?
+ "workspace.branches.banner.expand"
+ "workspace.branches.banner.collapse"))
+ :on-click on-toggle}
+ [:div {:class (stl/css :branch-banner-handle-btn)}]]
+ [:div {:class (stl/css :branch-banner-content)}
+ [:div {:class (stl/css :branch-banner-icon)}
+ [:> i/icon* {:icon-id i/git-branch}]]
+ [:div {:class (stl/css :branch-banner-text)}
+ [:span {:class (stl/css :branch-banner-title)}
+ (tr (if merged?
+ "workspace.branches.banner.merged"
+ "workspace.branches.banner.on-branch"))]
+ [:span {:class (stl/css :branch-banner-name)} (:name ctx)]
+ [:span {:class (stl/css :branch-banner-from)} (tr "workspace.branches.banner.from-main")]]
+
+ (if merged?
+ [:> button* {:variant "secondary"
+ :icon i/arrow-up-right
+ :on-click on-open-main}
+ (tr "workspace.branches.banner.view-main")]
+
+ (let [conflicts (or (:conflicts ctx) 0)]
+ [:div {:class (stl/css :branch-banner-actions)}
+ [:span {:class (stl/css :branch-banner-counts)}
+ [:span {:class (stl/css :count-ahead)}
+ [:> i/icon* {:icon-id i/arrow-up :size "s"}] (dm/str (:ahead ctx))]
+ [:span {:class (stl/css :count-behind)}
+ [:> i/icon* {:icon-id i/arrow-down :size "s"}] (dm/str (:behind ctx))]]
+
+ (when (pos? conflicts)
+ [:span {:class (stl/css :item-badge :badge-conflict)}
+ (tr "workspace.branches.banner.conflicts" (dm/str conflicts))])
+
+ [:> button* {:variant "secondary"
+ :icon i/switch
+ :on-click on-compare}
+ (tr "workspace.branches.compare")]
+
+ (cond
+ (pos? conflicts)
+ [:> button* {:variant "primary"
+ :icon i/triangle-alert
+ :on-click on-resolve}
+ (tr "workspace.branches.conflicts.resolve")]
+
+ (pos? (:behind ctx))
+ [:> button* {:variant "primary"
+ :icon i/status-update
+ :on-click on-update}
+ (tr "workspace.branches.update")]
+
+ ;; with pull requests enabled the primary flow is asking
+ ;; for a review (merging moves behind the kebab menu)
+ (and prs-enabled? (some? open-pr))
+ [:> button* {:variant "primary"
+ :icon i/git-pull-request-arrow
+ :on-click on-open-review}
+ (tr "workspace.pull-requests.actions.open-review")]
+
+ (and prs-enabled? (pos? (:ahead ctx)))
+ [:> button* {:variant "primary"
+ :icon i/git-pull-request-arrow
+ :on-click on-request-review}
+ (tr "workspace.pull-requests.menu.request-review")]
+
+ (pos? (:ahead ctx))
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :on-click on-merge}
+ (tr "workspace.branches.merge.action")])
+
+ (when prs-enabled?
+ [:div {:class (stl/css :branch-banner-menu)}
+ [:> icon-button* {:variant "ghost"
+ :icon i/menu
+ :aria-label (tr "labels.options")
+ :on-click on-open-menu}]
+ [:> dropdown-menu* {:show menu-open?
+ :on-close on-close-menu
+ :class (stl/css :branch-banner-options-dropdown)}
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-merge}
+ (tr "workspace.pull-requests.actions.merge")]]])]))]])))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/branches.scss b/frontend/src/app/main/ui/workspace/sidebar/branches.scss
new file mode 100644
index 0000000000..c5aebdd155
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/sidebar/branches.scss
@@ -0,0 +1,1524 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC Sucursal en España SL
+
+@use "ds/_sizes.scss" as *;
+@use "ds/typography.scss" as t;
+@use "refactor/common-refactor.scss" as deprecated;
+
+// All branch change-count numbers and changed-value indicators (counts,
+// ahead/behind, resolved/pending, property values) use the monospace
+// inspect font at 600 weight, for consistent, legible figures.
+@mixin branch-count {
+ @include t.use-typography("code-font");
+
+ font-weight: 600;
+}
+
+// --- Light-theme semantic tints
+//
+// The DS exposes the same lightened `--color-foreground-{success,error,warning,
+// info}` in both themes. That tint reads well over the dark tint backgrounds of
+// the default (dark) theme, but washes out over the pale pastel backgrounds the
+// light theme uses (see the success/info/error/warning chips and badges). For
+// this feature's surfaces we re-point those foregrounds to the more saturated
+// `--color-accent-*` tokens in the light theme only — reusing existing DS
+// variables, so there are no new variables, no global override and the dark
+// theme is untouched.
+.compare-overlay,
+.branches-toolbox,
+.branch-banner {
+ :global(.light) & {
+ --color-foreground-success: var(--color-accent-success);
+ --color-foreground-error: var(--color-accent-error);
+
+ // accent-warning is a bright orange that still washes out over the pale
+ // warning background; darken it a touch (toward black) so it reads.
+ --color-foreground-warning: color-mix(in srgb, var(--color-accent-warning) 60%, #000);
+ --color-foreground-info: var(--color-accent-info);
+ }
+}
+
+// --- Panel
+
+.branches-toolbox {
+ display: grid;
+ grid-template-rows: auto 1fr;
+ gap: var(--sp-s);
+ height: 100%;
+ padding: var(--sp-s);
+ overflow: hidden;
+}
+
+.branches-header {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+}
+
+.branches-empty {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxxl);
+ margin: var(--sp-xxxl) auto;
+ inline-size: $sz-200;
+}
+
+.branches-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.branches-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-block: var(--sp-s) var(--sp-xs);
+ padding-inline: var(--sp-s);
+
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+}
+
+.branches-section-count {
+ color: var(--color-foreground-secondary);
+}
+
+.branches-entries {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.branch-entry {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-s);
+ border-radius: 8px;
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--color-background-tertiary);
+ }
+}
+
+.branch-entry-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 32px;
+ block-size: 32px;
+ border-radius: 8px;
+ background-color: var(--color-background-tertiary);
+ color: var(--color-accent-primary);
+}
+
+.branch-entry-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+ min-width: 0;
+}
+
+.branch-entry-name {
+ @include t.use-typography("body-medium");
+
+ color: var(--color-foreground-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.branch-entry-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ min-width: 0;
+}
+
+.branch-entry-author {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.branch-entry-badges {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ color: var(--color-foreground-secondary);
+}
+
+.branch-pr-approvals {
+ @include t.use-typography("body-small");
+
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-xxs);
+ color: var(--color-foreground-secondary);
+}
+
+.branch-entry-aside {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+}
+
+.branch-entry-counts {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+
+ @include t.use-typography("body-small");
+}
+
+.count-ahead,
+.count-behind {
+ @include branch-count;
+
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-xxs);
+ color: var(--color-foreground-secondary);
+}
+
+.count-ahead {
+ color: var(--color-accent-primary);
+}
+
+// --- Create branch dialog
+
+.modal-overlay {
+ @extend %modal-overlay-base;
+}
+
+.modal-container {
+ @extend %modal-container-base;
+
+ min-width: deprecated.$s-408;
+}
+
+.modal-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--sp-s);
+ margin-bottom: deprecated.$s-24;
+}
+
+.modal-header-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: $sz-32;
+ height: $sz-32;
+ border-radius: 8px;
+ color: var(--color-accent-primary);
+ background-color: var(--color-background-tertiary);
+}
+
+.modal-header-text {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+ flex: 1;
+}
+
+.modal-title {
+ @include deprecated.headline-medium-typography;
+
+ color: var(--modal-title-foreground-color);
+}
+
+.modal-subtitle {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+.modal-content {
+ @include deprecated.flex-column;
+
+ gap: deprecated.$s-16;
+ margin-bottom: deprecated.$s-24;
+}
+
+// Merge-dialog confirmation prompt: a regular-weight, secondary-color
+// paragraph sitting between the title and the archive controls, with a
+// touch more breathing room before the checkbox.
+.modal-message {
+ @include t.use-typography("body-large");
+
+ margin: 0;
+ margin-block-end: var(--sp-s);
+ color: var(--color-foreground-secondary);
+}
+
+// The branch name and "main" stand out from the secondary-colored prompt.
+.modal-message-emphasis {
+ color: var(--color-foreground-primary);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+}
+
+.field-label {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+.textarea {
+ width: 100%;
+ min-height: $sz-64;
+ padding: var(--sp-s);
+ border-radius: 8px;
+ border: 1px solid var(--color-background-quaternary);
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-primary);
+ resize: vertical;
+
+ @include t.use-typography("body-medium");
+}
+
+.derive-from {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-s);
+ border-radius: 8px;
+ background-color: var(--color-background-tertiary);
+}
+
+.derive-from-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background-color: var(--color-accent-primary);
+}
+
+.derive-from-name {
+ @include t.use-typography("body-medium");
+
+ color: var(--color-foreground-primary);
+ font-weight: 700;
+}
+
+.derive-from-hint {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--sp-s);
+}
+
+// --- Branch info dialog
+
+.info-container {
+ min-width: deprecated.$s-456;
+}
+
+// status chip sits between the title block and the close button
+.info-status {
+ @include t.use-typography("body-small");
+
+ align-self: flex-start;
+ padding: var(--sp-xxs) var(--sp-s);
+ border-radius: 12px;
+ font-weight: 600;
+ text-transform: capitalize;
+ white-space: nowrap;
+
+ &.status-open {
+ color: var(--color-foreground-info);
+ background-color: var(--color-background-info);
+ }
+
+ &.status-merged {
+ color: var(--color-accent-primary);
+ background-color: var(--color-background-tertiary);
+ }
+
+ &.status-archived {
+ color: var(--color-foreground-secondary);
+ background-color: var(--color-background-tertiary);
+ }
+}
+
+.info-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+}
+
+.info-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--sp-s);
+ min-block-size: 28px;
+}
+
+.info-desc {
+ @include t.use-typography("body-medium");
+
+ margin: 0;
+ padding: var(--sp-s);
+ border-radius: 8px;
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-primary);
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+}
+
+.info-desc-empty {
+ @include t.use-typography("body-medium");
+
+ margin: 0;
+ padding: var(--sp-s);
+ border-radius: 8px;
+ border: 1px dashed var(--color-background-quaternary);
+ color: var(--color-foreground-secondary);
+ font-style: italic;
+}
+
+.info-desc-edit {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-s);
+}
+
+.info-desc-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--sp-s);
+}
+
+.info-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin: 0;
+ border-radius: 8px;
+ border: 1px solid var(--color-background-quaternary);
+ overflow: hidden;
+}
+
+.info-row {
+ display: grid;
+ grid-template-columns: minmax(120px, 0.5fr) 1fr;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-s);
+
+ &:not(:last-child) {
+ border-block-end: 1px solid var(--color-background-quaternary);
+ }
+}
+
+.info-key {
+ @include t.use-typography("body-small");
+
+ margin: 0;
+ color: var(--color-foreground-secondary);
+}
+
+.info-val {
+ @include t.use-typography("body-medium");
+
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ margin: 0;
+ color: var(--color-foreground-primary);
+ min-width: 0;
+}
+
+.info-counts {
+ gap: var(--sp-s);
+}
+
+// --- Compare changes dialog
+
+.compare-overlay {
+ @extend %modal-overlay-base;
+}
+
+.compare-container {
+ @extend %modal-container-base;
+ @include deprecated.menu-shadow;
+
+ display: flex;
+ flex-direction: column;
+ inline-size: min(95vw, 1000px);
+ block-size: min(88vh, 700px);
+ min-width: 0;
+ min-height: 0;
+ max-width: none;
+ max-height: none;
+ padding: deprecated.$s-16;
+ overflow: hidden;
+}
+
+.compare-header {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+ padding-bottom: var(--sp-s);
+ border-bottom: 1px solid var(--color-background-quaternary);
+}
+
+.compare-title-group {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+}
+
+.compare-title-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 36px;
+ block-size: 36px;
+ border-radius: 8px;
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-secondary);
+}
+
+.compare-title-text {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+}
+
+.compare-subtitle {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+.title-icon-warning {
+ color: var(--color-foreground-warning);
+}
+
+.compare-breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+.breadcrumb-branch {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-xxs);
+ padding: var(--sp-xxs) var(--sp-s);
+ border-radius: 8px;
+ background-color: var(--color-background-tertiary);
+ color: var(--color-accent-primary);
+}
+
+.breadcrumb-main {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-xxs);
+ padding: var(--sp-xxs) var(--sp-s);
+ border-radius: 8px;
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-secondary);
+}
+
+.breadcrumb-swap {
+ @include deprecated.button-style;
+ @include deprecated.flex-center;
+
+ padding: var(--sp-xxs);
+ border-radius: var(--sp-xs);
+ color: var(--color-foreground-secondary);
+ cursor: pointer;
+
+ &:hover {
+ color: var(--color-accent-primary);
+ }
+}
+
+.compare-stats {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ margin-inline-start: auto;
+}
+
+.stat-badge,
+.item-badge {
+ @include t.use-typography("body-small");
+
+ padding: var(--sp-xxs) var(--sp-s);
+ border-radius: 12px;
+ white-space: nowrap;
+}
+
+.badge-added {
+ color: var(--color-foreground-success);
+ background-color: var(--color-background-success);
+}
+
+.badge-modified {
+ color: var(--color-foreground-info);
+ background-color: var(--color-background-info);
+}
+
+.badge-deleted {
+ color: var(--color-foreground-error);
+ background-color: var(--color-background-error);
+}
+
+.badge-conflict {
+ color: var(--color-foreground-warning);
+ background-color: var(--color-background-warning);
+}
+
+.compare-empty {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ justify-content: center;
+}
+
+.compare-body {
+ display: grid;
+ grid-template-columns: minmax(320px, 1fr) 2fr;
+ flex: 1;
+ min-height: 0;
+}
+
+.compare-side {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.compare-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--sp-xs);
+ padding: var(--sp-s);
+}
+
+.filter-chip {
+ @include t.use-typography("body-small");
+
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ padding: var(--sp-xxs) var(--sp-s);
+ border-radius: 16px;
+ border: 1px solid transparent;
+ background: none;
+ cursor: pointer;
+ color: var(--color-foreground-secondary);
+
+ &:hover {
+ background-color: var(--color-background-tertiary);
+ }
+
+ &.is-active {
+ border-color: var(--color-background-quaternary);
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-primary);
+ }
+}
+
+.filter-dot {
+ inline-size: 8px;
+ block-size: 8px;
+ border-radius: 50%;
+ background-color: var(--color-foreground-secondary);
+
+ .dot-added & {
+ background-color: var(--color-foreground-success);
+ }
+
+ .dot-modified & {
+ background-color: var(--color-foreground-info);
+ }
+
+ .dot-deleted & {
+ background-color: var(--color-foreground-error);
+ }
+}
+
+.compare-list {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: var(--sp-m);
+ min-height: 0;
+ padding: 0 var(--sp-s) var(--sp-s);
+ overflow-y: auto;
+}
+
+.compare-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+}
+
+.compare-group-head {
+ @include t.use-typography("body-small");
+
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ padding: var(--sp-xs);
+ color: var(--color-foreground-secondary);
+}
+
+.compare-group-label {
+ flex: 1;
+ font-weight: 600;
+}
+
+.compare-group-counts {
+ @include branch-count;
+
+ display: inline-flex;
+ gap: var(--sp-xs);
+}
+
+.count-added {
+ color: var(--color-foreground-success);
+}
+
+.count-modified {
+ color: var(--color-foreground-info);
+}
+
+.count-deleted {
+ color: var(--color-foreground-error);
+}
+
+.compare-group-items {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.compare-item {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-s);
+ border-radius: 8px;
+ border-inline-start: 3px solid transparent;
+ background-color: var(--color-background-tertiary);
+ cursor: pointer;
+
+ &:hover,
+ &.is-selected {
+ background-color: var(--color-background-quaternary);
+ }
+
+ &.status-added {
+ border-inline-start-color: var(--color-foreground-success);
+ }
+
+ &.status-modified {
+ border-inline-start-color: var(--color-foreground-info);
+ }
+
+ &.status-deleted {
+ border-inline-start-color: var(--color-foreground-error);
+ }
+
+ &.status-conflict {
+ border-inline-start-color: var(--color-foreground-warning);
+ }
+}
+
+.compare-item-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-foreground-secondary);
+}
+
+.compare-item-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+ min-width: 0;
+}
+
+.compare-item-label {
+ @include t.use-typography("body-medium");
+
+ color: var(--color-foreground-primary);
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.compare-item-subtitle {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.compare-detail {
+ min-height: 0;
+ padding: var(--sp-l);
+ overflow-y: auto;
+ border-inline-start: 1px solid var(--color-background-quaternary);
+}
+
+.compare-detail-empty {
+ display: flex;
+ block-size: 100%;
+ align-items: center;
+ justify-content: center;
+}
+
+.compare-detail-content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-l);
+}
+
+.compare-detail-head {
+ display: flex;
+ align-items: baseline;
+ gap: var(--sp-s);
+}
+
+.compare-detail-title {
+ @include t.use-typography("body-medium");
+
+ margin: 0;
+ color: var(--color-foreground-primary);
+ font-weight: 600;
+}
+
+.compare-detail-type {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.compare-detail-subtitle {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+.compare-detail-hint {
+ @include t.use-typography("body-medium");
+
+ color: var(--color-foreground-secondary);
+}
+
+.prop-changes {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--color-background-quaternary);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.prop-changes-head {
+ @include t.use-typography("body-medium");
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--sp-s) var(--sp-m);
+ border-bottom: 1px solid var(--color-background-quaternary);
+ color: var(--color-foreground-primary);
+}
+
+.prop-changes-count {
+ @include branch-count;
+
+ color: var(--color-foreground-secondary);
+}
+
+.prop-row {
+ display: grid;
+ grid-template-columns: minmax(120px, 1fr) 2fr;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-s) var(--sp-m);
+ border-bottom: 1px solid var(--color-background-quaternary);
+
+ &:last-child {
+ border-bottom: none;
+ }
+}
+
+.prop-name {
+ @include t.use-typography("body-medium");
+
+ color: var(--color-foreground-secondary);
+}
+
+.prop-values {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+ color: var(--color-foreground-secondary);
+}
+
+.value-chip {
+ @include branch-count;
+
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-xxs) var(--sp-xs);
+ border-radius: var(--sp-xs);
+}
+
+.value-main {
+ color: var(--color-foreground-error);
+ background-color: var(--color-background-error);
+}
+
+.value-branch {
+ color: var(--color-foreground-success);
+ background-color: var(--color-background-success);
+}
+
+.value-base {
+ color: var(--color-foreground-secondary);
+ background-color: var(--color-background-tertiary);
+}
+
+.value-swatch {
+ inline-size: 16px;
+ block-size: 16px;
+ border-radius: var(--sp-xs);
+ border: 1px solid var(--color-background-quaternary);
+}
+
+.compare-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--sp-s);
+ padding-top: var(--sp-s);
+ border-top: 1px solid var(--color-background-quaternary);
+}
+
+.compare-footer-info {
+ @include t.use-typography("body-small");
+
+ display: flex;
+ gap: var(--sp-xs);
+ color: var(--color-foreground-secondary);
+}
+
+.compare-footer-conflicts {
+ color: var(--color-foreground-warning);
+}
+
+.compare-footer-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+}
+
+// --- Resolve conflicts dialog
+
+.conflicts-progress {
+ @include branch-count;
+
+ margin-inline-start: auto;
+ padding: var(--sp-xxs) var(--sp-s);
+ border-radius: 12px;
+ color: var(--color-foreground-info);
+ background-color: var(--color-background-info);
+}
+
+.conflicts-toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-s) 0;
+ border-bottom: 1px solid var(--color-background-quaternary);
+}
+
+.bulk-label {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+.conflicts-pending {
+ @include branch-count;
+
+ margin-inline-start: auto;
+ color: var(--color-foreground-warning);
+}
+
+// left list
+
+.conflicts-list-side {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+ min-height: 0;
+ padding-block-start: var(--sp-s);
+}
+
+.conflicts-list-head {
+ @include t.use-typography("body-small");
+
+ padding-inline: var(--sp-s);
+ color: var(--color-foreground-secondary);
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.conflict-item {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-s);
+ border-radius: 8px;
+ border: 1px solid transparent;
+ background-color: var(--color-background-tertiary);
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--color-background-quaternary);
+ }
+
+ &.is-selected {
+ border-color: var(--color-background-quaternary);
+ background-color: var(--color-background-quaternary);
+ }
+
+ // a selected-but-unresolved conflict gets the warning outline
+ &.is-selected.is-pending {
+ border-color: var(--color-foreground-warning);
+ }
+}
+
+.conflict-item-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-foreground-secondary);
+}
+
+.conflict-item-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+ min-width: 0;
+}
+
+.conflict-item-label {
+ @include t.use-typography("body-medium");
+
+ color: var(--color-foreground-primary);
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.conflict-item-subtitle {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.conflict-pill {
+ @include t.use-typography("body-small");
+
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ padding: var(--sp-xxs) var(--sp-s);
+ border-radius: 12px;
+}
+
+.pill-dot {
+ inline-size: 6px;
+ block-size: 6px;
+ border-radius: 50%;
+ background-color: currentcolor;
+}
+
+.pill-main {
+ color: var(--color-foreground-secondary);
+ background-color: var(--color-background-tertiary);
+}
+
+.pill-branch {
+ color: var(--color-accent-primary);
+ background-color: var(--color-background-tertiary);
+}
+
+.pill-mixed {
+ color: var(--color-foreground-info);
+ background-color: var(--color-background-info);
+}
+
+.pill-pending {
+ color: var(--color-foreground-warning);
+ background-color: var(--color-background-warning);
+}
+
+// right detail
+
+.conflict-detail-head {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+ margin-block-end: var(--sp-l);
+}
+
+.conflict-detail-titlerow {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+}
+
+.conflict-detail-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-foreground-warning);
+}
+
+.conflict-detail-title {
+ @include t.use-typography("headline-small");
+
+ margin: 0;
+ color: var(--color-foreground-primary);
+ font-weight: 600;
+}
+
+.conflict-detail-subtitle {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+// header: global per-element actions sit at the end of the title row
+.conflict-global-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ margin-inline-start: auto;
+}
+
+// --- preview strip: BASE · MAIN · BRANCH · RESULT
+
+.preview-row {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--sp-s);
+ align-items: start;
+ margin: var(--sp-l) 0;
+}
+
+.preview-col {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--sp-xs);
+ min-width: 0;
+
+ &.is-result .preview-box {
+ border-color: var(--color-accent-primary);
+ }
+}
+
+.preview-col-label {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ font-weight: 600;
+ text-transform: uppercase;
+
+ .is-result & {
+ color: var(--color-accent-primary);
+ }
+}
+
+.preview-col-sub {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-inline-size: 100%;
+}
+
+.preview-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 100%;
+ aspect-ratio: 1;
+ padding: var(--sp-s);
+ border-radius: 8px;
+ border: 2px solid var(--color-background-quaternary);
+ background-color: var(--color-background-primary);
+
+ // checkerboard so transparency / light fills read clearly
+ background-image:
+ linear-gradient(45deg, var(--color-background-tertiary) 25%, transparent 25%),
+ linear-gradient(-45deg, var(--color-background-tertiary) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, var(--color-background-tertiary) 75%),
+ linear-gradient(-45deg, transparent 75%, var(--color-background-tertiary) 75%);
+ background-size: 12px 12px;
+ background-position:
+ 0 0,
+ 0 6px,
+ 6px -6px,
+ -6px 0;
+ overflow: hidden;
+}
+
+.preview-svg {
+ max-inline-size: 100%;
+ max-block-size: 100%;
+}
+
+.preview-swatch {
+ inline-size: 100%;
+ block-size: 100%;
+ border-radius: 4px;
+}
+
+.preview-type-sample {
+ @include t.use-typography("headline-medium");
+
+ color: var(--color-foreground-primary);
+ line-height: 1;
+}
+
+.preview-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-foreground-secondary);
+}
+
+// --- per-property table
+
+.prop-table {
+ display: flex;
+ flex-direction: column;
+ border-radius: 8px;
+ border: 1px solid var(--color-background-quaternary);
+ overflow: hidden;
+}
+
+.prop-table-head,
+.prop-pick-row {
+ display: grid;
+ grid-template-columns: minmax(120px, 1fr) 1fr 1fr;
+ align-items: center;
+ gap: var(--sp-s);
+ padding: var(--sp-s);
+}
+
+.prop-table-head {
+ background-color: var(--color-background-tertiary);
+}
+
+.prop-table-th {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.prop-pick-row {
+ border-block-start: 1px solid var(--color-background-quaternary);
+}
+
+.prop-pick-name {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-primary);
+}
+
+.prop-pick {
+ @include deprecated.button-style;
+
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ padding: var(--sp-xs);
+ border-radius: 8px;
+ border: 2px solid transparent;
+ cursor: pointer;
+
+ &:hover {
+ border-color: var(--color-background-quaternary);
+ }
+
+ &.is-chosen {
+ border-color: var(--color-accent-primary);
+ background-color: var(--color-background-tertiary);
+ }
+}
+
+.conflict-whole-hint {
+ @include t.use-typography("body-medium");
+
+ color: var(--color-foreground-secondary);
+}
+
+// --- Branch context banner
+
+.branch-banner {
+ position: absolute;
+ inset-block-end: var(--sp-m);
+ inset-inline-start: 50%;
+ transform: translateX(-50%);
+ max-inline-size: 90vw;
+ border-radius: deprecated.$s-8;
+ border: deprecated.$s-2 solid var(--panel-border-color);
+ background-color: var(--color-background-primary);
+
+ // the kebab dropdown opens upwards, past the banner box: it must not
+ // be clipped (the collapsed state hides its content via block-size +
+ // visibility) and it must beat the viewport controls, which are
+ // raised to z-index 1 while a drawing tool is active
+ overflow: visible;
+ z-index: deprecated.$z-index-2;
+ transition:
+ inset-block-end 0.2s,
+ block-size 0.2s;
+
+ // collapsed: hide the content and stick the handle strip flush to the
+ // bottom of the canvas (rounded top corners only). The banner lives at the
+ // bottom, so it folds DOWN — mirror of the toolbar (which folds up).
+ // NOTE: the content keeps `visibility: hidden` (not `display: none`) so it
+ // still defines the bar's WIDTH — otherwise the collapsed bar shrinks to
+ // the handle's width and looks broken.
+ &.is-collapsed {
+ inset-block-end: 0;
+ block-size: var(--sp-m);
+ border-radius: 12px 12px 0 0;
+
+ .branch-banner-content {
+ block-size: 0;
+ padding-block: 0;
+ visibility: hidden;
+ }
+ }
+}
+
+.branch-banner-handle {
+ @include deprecated.flex-center;
+ @include deprecated.button-style;
+
+ // overlaps the content's top padding so it stays snug without making the
+ // banner taller; fills the whole strip when collapsed
+ position: absolute;
+ inset-inline: 0;
+ inset-block-start: 0;
+ block-size: var(--sp-m);
+ inline-size: 100%;
+ z-index: 1;
+}
+
+.branch-banner-handle-btn {
+ block-size: var(--sp-xs);
+ inline-size: 100%;
+ max-inline-size: 64px;
+ padding: 0;
+ border-radius: var(--sp-xs);
+ background-color: var(--palette-handler-background-color);
+}
+
+.branch-banner-content {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-m);
+
+ // extra top room for the grip handle that overlaps it; no overflow
+ // clip, the kebab dropdown opens upwards past the banner edge
+ padding: var(--sp-m) var(--sp-m) var(--sp-s);
+}
+
+.branch-banner-menu {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+// the banner sits at the bottom of the canvas, so its options menu
+// opens upwards
+.branch-banner-options-dropdown {
+ @extend %menu-dropdown;
+
+ inset-block-end: 100%;
+ inset-inline-end: 0;
+ inline-size: $sz-192;
+}
+
+.branch-banner-icon {
+ display: flex;
+ align-items: center;
+ color: var(--color-accent-primary);
+
+ .branch-banner-merged & {
+ color: var(--color-foreground-secondary);
+ }
+}
+
+.branch-banner-text {
+ display: flex;
+ align-items: baseline;
+ gap: var(--sp-xs);
+
+ @include t.use-typography("body-small");
+}
+
+.branch-banner-title,
+.branch-banner-from {
+ color: var(--color-foreground-secondary);
+}
+
+.branch-banner-name {
+ color: var(--color-foreground-primary);
+ font-weight: 700;
+}
+
+.branch-banner-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+}
+
+.branch-banner-counts {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+
+ @include t.use-typography("body-small");
+}
+
+// --- Lifecycle: kebab menu, rename, archived section
+
+.branch-entry.is-archived {
+ opacity: 0.7;
+}
+
+.branch-entry.is-current {
+ border: 1px solid var(--color-background-quaternary);
+ background-color: var(--color-background-tertiary);
+}
+
+// while its context menu is open, lift the whole entry (and its absolutely
+// positioned dropdown) above sibling entries so their hover/background does
+// not paint over the menu
+.branch-entry.is-menu-open {
+ position: relative;
+ z-index: 1;
+}
+
+.branch-rename-input {
+ inline-size: 100%;
+ padding: var(--sp-xxs) var(--sp-xs);
+ border-radius: 8px;
+ border: 1px solid var(--color-accent-primary);
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-primary);
+
+ @include t.use-typography("body-medium");
+}
+
+.branch-options-dropdown {
+ @extend %menu-dropdown;
+
+ inset-block-start: 100%;
+ inset-inline-end: 0;
+ inline-size: $sz-160;
+}
+
+.menu-option {
+ @extend %menu-item-base;
+
+ white-space: nowrap;
+}
+
+.branches-archived {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+}
+
+.branches-archived-toggle {
+ @include t.use-typography("body-small");
+
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ padding-block: var(--sp-s) var(--sp-xs);
+ padding-inline: var(--sp-s);
+ border: none;
+ background: none;
+ cursor: pointer;
+ color: var(--color-foreground-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+}
+
+.branches-archived-count {
+ margin-inline-start: auto;
+}
diff --git a/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs
new file mode 100644
index 0000000000..be35c6367d
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs
@@ -0,0 +1,614 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License v. 2.0. If a copy of the MPL was not distributed with this
+;; file You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC Sucursal en España SL
+
+(ns app.main.ui.workspace.sidebar.pull-requests
+ "Pull request UI. A pull request is presented as a STATE of its branch
+ (one branch → at most one open pull request), so there is no separate
+ pull request list: the branch entries in the sidebar surface the review
+ state (see `app.main.ui.workspace.sidebar.branches`), and this namespace
+ provides the shared pieces — state badge, action helpers, the creation /
+ review / details modals and the review-sandbox banner."
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data.macros :as dm]
+ [app.common.time :as ct]
+ [app.common.uuid :as uuid]
+ [app.main.data.modal :as modal]
+ [app.main.data.team :as dtm]
+ [app.main.data.workspace.pull-requests :as dwpr]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.ui.components.dropdown-menu :refer [dropdown-menu*
+ dropdown-menu-item*]]
+ [app.main.ui.ds.buttons.button :refer [button*]]
+ [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
+ [app.main.ui.ds.controls.input :refer [input*]]
+ [app.main.ui.ds.foundations.assets.icon :as i]
+ [app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
+ [app.main.ui.ds.product.avatar :refer [avatar*]]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
+
+;; --- Derived display state
+
+(defn- display-state
+ "The single most relevant label for a pull request: its stored status
+ for finished ones, the aggregate review verdict for open ones."
+ [{:keys [status review-state]}]
+ (case status
+ "merged" :merged
+ "closed" :closed
+ (case review-state
+ "changes-requested" :changes-requested
+ "approved" :approved
+ "in-review" :in-review
+ :open)))
+
+(def ^:private state->label
+ {:open "workspace.pull-requests.state.open"
+ :in-review "workspace.pull-requests.state.in-review"
+ :approved "workspace.pull-requests.state.approved"
+ :changes-requested "workspace.pull-requests.state.changes-requested"
+ :merged "workspace.pull-requests.state.merged"
+ :closed "workspace.pull-requests.state.closed"})
+
+(mf/defc pr-state-badge*
+ [{:keys [pr]}]
+ (let [state (display-state pr)]
+ [:span {:class (stl/css-case :pr-badge true
+ :badge-open (= state :open)
+ :badge-in-review (= state :in-review)
+ :badge-approved (= state :approved)
+ :badge-changes (= state :changes-requested)
+ :badge-merged (= state :merged)
+ :badge-closed (= state :closed))}
+ (tr (get state->label state))]))
+
+(defn current-approvals
+ "[current-approvals total-reviewers] of a pull request; stale verdicts
+ do not count."
+ [{:keys [reviews]}]
+ [(count (filter #(and (= "approved" (:state %)) (not (:stale %))) reviews))
+ (count reviews)])
+
+(defn- pr->branch
+ "A branch-row-shaped map for the pull request's branch, enough for the
+ branching compare / merge dialogs (they key on :id and :name; the
+ counts feed their action buttons)."
+ [pr]
+ {:id (:file-branch-id pr)
+ :name (:branch-name pr)
+ :status "open"
+ :ahead (:ahead pr)
+ :behind (:behind pr)
+ :conflicts (:conflicts pr)})
+
+(defn open-compare!
+ "Open the branching compare dialog on the pull request's branch, so the
+ proposed changes can be inspected. Like merging, it diffs the LIVE
+ branch against main."
+ [pr]
+ (modal/show! :branch-compare {:branch (pr->branch pr)}))
+
+(defn confirm-merge!
+ "Open the branching merge dialog for the pull request's branch:
+ accepting a pull request goes through the exact same integration flow
+ (confirmation, conflict resolution, safety snapshot) as merging the
+ branch directly. Merging always integrates the LIVE branch state, so
+ an outdated pull request merges the newest changes, not the snapshot."
+ [pr]
+ (modal/show! :merge-branch {:branch (pr->branch pr)}))
+
+(defn confirm-close!
+ "Ask for confirmation before cancelling a pull request: the review is
+ discarded and the branch simply goes back to being a normal branch
+ (it is NOT deleted)."
+ [pr]
+ (st/emit! (modal/show {:type :confirm
+ :title (tr "workspace.pull-requests.close.title")
+ :message (tr "workspace.pull-requests.close.message" (:title pr))
+ :accept-label (tr "workspace.pull-requests.actions.close")
+ :accept-style :danger
+ :on-accept (fn [_] (st/emit! (dwpr/close-pull-request (:id pr))))})))
+
+;; --- Create pull request dialog (modal)
+
+(mf/defc create-pull-request-dialog*
+ {::mf/register modal/components
+ ::mf/register-as :create-pull-request}
+ [{:keys [branch]}]
+ (let [profile (mf/deref refs/profile)
+ team (mf/deref refs/team)
+ members (->> (get team :members)
+ (filter :is-active)
+ (remove #(= (:id %) (:id profile))))
+
+ title* (mf/use-state (or (:name branch) ""))
+ title (deref title*)
+ description* (mf/use-state "")
+ description (deref description*)
+ selected* (mf/use-state #{})
+ selected (deref selected*)
+ valid? (not (str/blank? title))
+
+ on-title-change
+ (mf/use-fn #(reset! title* (dom/get-target-val %)))
+
+ on-description-change
+ (mf/use-fn #(reset! description* (dom/get-target-val %)))
+
+ on-toggle-reviewer
+ (mf/use-fn
+ (fn [event]
+ (let [id (-> (dom/get-current-target event)
+ (dom/get-data "reviewer-id")
+ (uuid/parse*))]
+ (swap! selected* #(if (contains? % id) (disj % id) (conj % id))))))
+
+ on-close
+ (mf/use-fn #(st/emit! (modal/hide)))
+
+ on-submit
+ (mf/use-fn
+ (mf/deps branch title description selected valid?)
+ (fn [_]
+ (when valid?
+ (st/emit! (dwpr/create-pull-request
+ (:id branch)
+ {:title (str/trim title)
+ :description (str/trim description)
+ :reviewers (vec selected)})
+ (modal/hide)))))]
+
+ ;; the reviewer picker needs the team members loaded
+ (mf/with-effect []
+ (st/emit! (dtm/fetch-members)))
+
+ [:div {:class (stl/css :modal-overlay)}
+ [:div {:class (stl/css :modal-container)}
+ [:div {:class (stl/css :modal-header)}
+ [:div {:class (stl/css :modal-header-icon)}
+ [:> i/icon* {:icon-id i/git-pull-request-arrow}]]
+ [:div {:class (stl/css :modal-header-text)}
+ [:h2 {:class (stl/css :modal-title)} (tr "workspace.pull-requests.create.title")]
+ [:span {:class (stl/css :modal-subtitle)}
+ (tr "workspace.pull-requests.create.subtitle" (or (:name branch) ""))]]
+ [:> button* {:variant "ghost"
+ :icon i/close
+ :aria-label (tr "labels.close")
+ :on-click on-close}]]
+
+ [:div {:class (stl/css :modal-content)}
+ [:> input* {:label (tr "workspace.pull-requests.create.name-label")
+ :icon i/git-pull-request-arrow
+ :placeholder (tr "workspace.pull-requests.create.name-placeholder")
+ :value title
+ :on-change on-title-change}]
+
+ [:div {:class (stl/css :field)}
+ [:label {:class (stl/css :field-label)}
+ (tr "workspace.pull-requests.create.description-label")]
+ [:textarea {:class (stl/css :textarea)
+ :value description
+ :placeholder (tr "workspace.pull-requests.create.description-placeholder")
+ :on-change on-description-change
+ :rows 3}]]
+
+ [:div {:class (stl/css :field)}
+ [:label {:class (stl/css :field-label)}
+ (tr "workspace.pull-requests.create.reviewers-label")]
+ (if (empty? members)
+ [:span {:class (stl/css :reviewers-empty)}
+ (tr "workspace.pull-requests.create.no-members")]
+ [:ul {:class (stl/css :reviewers-list)}
+ (for [member members]
+ [:li {:key (dm/str (:id member))}
+ [:button {:class (stl/css-case :reviewer-option true
+ :is-selected (contains? selected (:id member)))
+ :data-reviewer-id (dm/str (:id member))
+ :on-click on-toggle-reviewer}
+ [:> avatar* {:profile member :variant "S"}]
+ [:span {:class (stl/css :reviewer-name)} (:fullname member)]
+ (when (contains? selected (:id member))
+ [:> i/icon* {:icon-id i/tick :size "s"}])]])])]
+
+ [:> context-notification* {:level :info :type :context}
+ (tr "workspace.pull-requests.create.info")]]
+
+ [:div {:class (stl/css :modal-footer)}
+ [:> button* {:variant "secondary" :on-click on-close}
+ (tr "labels.cancel")]
+ [:> button* {:variant "primary"
+ :icon i/git-pull-request-arrow
+ :disabled (not valid?)
+ :on-click on-submit}
+ (tr "workspace.pull-requests.create.submit")]]]]))
+
+;; --- Review dialog (approve / request changes)
+
+(mf/defc pull-request-review-dialog*
+ {::mf/register modal/components
+ ::mf/register-as :pull-request-review}
+ [{:keys [pr]}]
+ (let [verdict* (mf/use-state "approved")
+ verdict (deref verdict*)
+ comment* (mf/use-state "")
+ comment (deref comment*)
+
+ on-comment-change
+ (mf/use-fn #(reset! comment* (dom/get-target-val %)))
+
+ on-select-verdict
+ (mf/use-fn
+ (fn [event]
+ (reset! verdict* (-> (dom/get-current-target event)
+ (dom/get-data "verdict")))))
+
+ on-close
+ (mf/use-fn #(st/emit! (modal/hide)))
+
+ on-submit
+ (mf/use-fn
+ (mf/deps pr verdict comment)
+ (fn [_]
+ (st/emit! (dwpr/submit-review (:id pr) verdict
+ (let [c (str/trim comment)]
+ (when-not (str/blank? c) c)))
+ (modal/hide))))]
+
+ [:div {:class (stl/css :modal-overlay)}
+ [:div {:class (stl/css :modal-container)}
+ [:div {:class (stl/css :modal-header)}
+ [:div {:class (stl/css :modal-header-icon)}
+ [:> i/icon* {:icon-id i/git-pull-request-arrow}]]
+ [:div {:class (stl/css :modal-header-text)}
+ [:h2 {:class (stl/css :modal-title)} (tr "workspace.pull-requests.review.title")]
+ [:span {:class (stl/css :modal-subtitle)} (:title pr)]]
+ [:> button* {:variant "ghost"
+ :icon i/close
+ :aria-label (tr "labels.close")
+ :on-click on-close}]]
+
+ [:div {:class (stl/css :modal-content)}
+ [:div {:class (stl/css :verdict-options)}
+ [:button {:class (stl/css-case :verdict-option true
+ :is-selected (= verdict "approved"))
+ :data-verdict "approved"
+ :on-click on-select-verdict}
+ [:> i/icon* {:icon-id i/tick}]
+ [:div {:class (stl/css :verdict-option-text)}
+ [:span {:class (stl/css :verdict-option-title)}
+ (tr "workspace.pull-requests.review.approve")]
+ [:span {:class (stl/css :verdict-option-hint)}
+ (tr "workspace.pull-requests.review.approve-hint")]]]
+
+ [:button {:class (stl/css-case :verdict-option true
+ :is-selected (= verdict "changes-requested"))
+ :data-verdict "changes-requested"
+ :on-click on-select-verdict}
+ [:> i/icon* {:icon-id i/triangle-alert}]
+ [:div {:class (stl/css :verdict-option-text)}
+ [:span {:class (stl/css :verdict-option-title)}
+ (tr "workspace.pull-requests.review.request-changes")]
+ [:span {:class (stl/css :verdict-option-hint)}
+ (tr "workspace.pull-requests.review.request-changes-hint")]]]]
+
+ [:div {:class (stl/css :field)}
+ [:label {:class (stl/css :field-label)}
+ (tr "workspace.pull-requests.review.comment-label")]
+ [:textarea {:class (stl/css :textarea)
+ :value comment
+ :placeholder (tr "workspace.pull-requests.review.comment-placeholder")
+ :on-change on-comment-change
+ :rows 3}]]]
+
+ [:div {:class (stl/css :modal-footer)}
+ [:> button* {:variant "secondary" :on-click on-close}
+ (tr "labels.cancel")]
+ [:> button* {:variant "primary"
+ :on-click on-submit}
+ (tr "workspace.pull-requests.review.submit")]]]]))
+
+;; --- Details dialog
+
+(mf/defc pull-request-info-dialog*
+ {::mf/register modal/components
+ ::mf/register-as :pull-request-info}
+ [{:keys [pr]}]
+ (let [profiles (mf/deref refs/profiles)
+ profile (mf/deref refs/profile)
+ team (mf/deref refs/team)
+ author (get profiles (:created-by pr))
+ author? (= (:created-by pr) (:id profile))
+ admin? (boolean (get-in team [:permissions :is-admin]))
+ can-merge? (boolean (get-in team [:permissions :can-edit]))
+ open? (= "open" (:status pr))
+ closed? (= "closed" (:status pr))
+ ;; already inside this pull request's review sandbox: offering to
+ ;; open it again would be a no-op
+ reviewing? (= (:id pr)
+ (:pr-id (mf/deref refs/pull-request-preview)))
+ [approvals total] (current-approvals pr)
+
+ on-close
+ (mf/use-fn #(st/emit! (modal/hide)))
+
+ on-merge
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [_]
+ (st/emit! (modal/hide))
+ (confirm-merge! pr)))
+
+ on-open-review
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [_]
+ (st/emit! (modal/hide)
+ (dwpr/open-pull-request pr))))
+
+ on-close-pr
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [_]
+ ;; the confirmation dialog replaces this modal
+ (confirm-close! pr)))
+
+ on-reopen-pr
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [_]
+ (st/emit! (modal/hide)
+ (dwpr/reopen-pull-request (:id pr)))))]
+
+ [:div {:class (stl/css :modal-overlay)}
+ [:div {:class (stl/css :modal-container)}
+ [:div {:class (stl/css :modal-header)}
+ [:div {:class (stl/css :modal-header-icon)}
+ [:> i/icon* {:icon-id i/git-pull-request-arrow}]]
+ [:div {:class (stl/css :modal-header-text)}
+ [:h2 {:class (stl/css :modal-title)} (:title pr)]
+ [:div {:class (stl/css :modal-subtitle-row)}
+ [:> pr-state-badge* {:pr pr}]
+ [:span {:class (stl/css :modal-subtitle)}
+ ;; the route reads branch → target BRANCH (reviews always merge
+ ;; into main); the target file name would be redundant here,
+ ;; inside the file itself
+ (dm/str (:branch-name pr) " → " (tr "workspace.branches.main"))]
+ (when (and open? (:outdated pr))
+ [:span {:class (stl/css :pr-badge :badge-outdated)}
+ (tr "workspace.pull-requests.badge.outdated")])
+ (when (and open? (pos? (or (:conflicts pr) 0)))
+ [:span {:class (stl/css :pr-badge :badge-conflicts)}
+ (tr "workspace.pull-requests.badge.conflicts" (dm/str (:conflicts pr)))])]]
+ [:> button* {:variant "ghost"
+ :icon i/close
+ :aria-label (tr "labels.close")
+ :on-click on-close}]]
+
+ [:div {:class (stl/css :modal-content)}
+ [:div {:class (stl/css :pr-info-card)}
+ [:div {:class (stl/css :pr-info-grid)}
+ [:div {:class (stl/css :pr-info-item)}
+ [:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.author")]
+ [:div {:class (stl/css :pr-info-value)}
+ (when author [:> avatar* {:profile author :variant "S"}])
+ [:span (get author :fullname "—")]]]
+
+ [:div {:class (stl/css :pr-info-item)}
+ [:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.created-at")]
+ [:div {:class (stl/css :pr-info-value)}
+ [:span (ct/format-inst (:created-at pr) :localized-date-time)]]]
+
+ [:div {:class (stl/css :pr-info-item)}
+ [:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.updated-at")]
+ [:div {:class (stl/css :pr-info-value)}
+ [:span (ct/format-inst (:review-updated-at pr) :localized-date-time)]]]
+
+ [:div {:class (stl/css :pr-info-item)}
+ [:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.approvals")]
+ [:div {:class (stl/css :pr-info-value)}
+ [:span (dm/str approvals "/" total)]]]]
+
+ (when (pos? total)
+ [:div {:class (stl/css :field)}
+ [:label {:class (stl/css :field-label)}
+ (tr "workspace.pull-requests.info.reviewers")]
+ [:ul {:class (stl/css :pr-info-reviewers)}
+ (for [review (:reviews pr)]
+ (let [reviewer (get profiles (:profile-id review))]
+ [:li {:key (dm/str (:profile-id review))
+ :class (stl/css :pr-info-reviewer)}
+ (when reviewer [:> avatar* {:profile reviewer :variant "S"}])
+ [:span {:class (stl/css :reviewer-name)}
+ (get reviewer :fullname "—")]
+ [:span {:class (stl/css-case :reviewer-state true
+ :reviewer-approved (and (= "approved" (:state review))
+ (not (:stale review)))
+ :reviewer-changes (and (= "changes-requested" (:state review))
+ (not (:stale review)))
+ :reviewer-stale (:stale review))}
+ (cond
+ (:stale review)
+ (tr "workspace.pull-requests.review-state.stale")
+
+ (= "approved" (:state review))
+ (tr "workspace.pull-requests.review-state.approved")
+
+ (= "changes-requested" (:state review))
+ (tr "workspace.pull-requests.review-state.changes-requested")
+
+ :else
+ (tr "workspace.pull-requests.review-state.pending"))]
+ (when-not (str/blank? (:comment review))
+ [:p {:class (stl/css :reviewer-comment)} (:comment review)])]))]])]
+
+ (when-not (str/blank? (:description pr))
+ [:div {:class (stl/css :pr-info-description-block)}
+ [:label {:class (stl/css :field-label)}
+ (tr "workspace.pull-requests.info.description")]
+ [:p {:class (stl/css :pr-info-description)} (:description pr)]])]
+
+ [:div {:class (stl/css :modal-footer)}
+ (when (and (or author? admin?) open?)
+ [:> button* {:variant "secondary"
+ :on-click on-close-pr}
+ (tr "workspace.pull-requests.actions.close")])
+ (when (and (or author? admin?) closed?)
+ [:> button* {:variant "secondary"
+ :on-click on-reopen-pr}
+ (tr "workspace.pull-requests.actions.reopen")])
+ (when (and open? (not reviewing?))
+ [:> button* {:variant "primary"
+ :icon i/arrow-up-right
+ :on-click on-open-review}
+ (tr "workspace.pull-requests.actions.open-review")])
+ (when (and open? can-merge?)
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :on-click on-merge}
+ (tr "workspace.pull-requests.actions.merge")])]]]))
+
+;; --- Review sandbox banner (replaces the branch banner inside a sandbox)
+
+(mf/defc pr-review-banner*
+ []
+ (let [preview (mf/deref refs/pull-request-preview)
+ pr (:info preview)
+ profile (mf/deref refs/profile)
+ team (mf/deref refs/team)
+ author? (= (:created-by pr) (:id profile))
+ reviewer? (some #(= (:profile-id %) (:id profile)) (:reviews pr))
+ ;; merging keeps requiring edition permissions on main: the
+ ;; sandbox file permissions are stripped, but the team role tells
+ ;; whether this user could integrate the branch
+ can-merge? (boolean (get-in team [:permissions :can-edit]))
+ [approvals total] (current-approvals pr)
+
+ collapsed* (mf/use-state false)
+ collapsed? (deref collapsed*)
+ on-toggle (mf/use-fn #(swap! collapsed* not))
+
+ ;; secondary actions live behind the kebab menu so the banner
+ ;; only exposes the essential ones (details / compare / review)
+ menu-open* (mf/use-state false)
+ menu-open? (deref menu-open*)
+ on-open-menu (mf/use-fn
+ (fn [event]
+ ;; the dropdown closes itself on any outside
+ ;; click: the opening one must not reach it
+ (dom/stop-propagation event)
+ (reset! menu-open* true)))
+ on-close-menu (mf/use-fn #(reset! menu-open* false))
+
+ on-info
+ (mf/use-fn (mf/deps pr) #(modal/show! :pull-request-info {:pr pr}))
+
+ on-compare
+ (mf/use-fn (mf/deps pr) #(open-compare! pr))
+
+ on-review
+ (mf/use-fn (mf/deps pr) #(modal/show! :pull-request-review {:pr pr}))
+
+ on-exit
+ (mf/use-fn
+ (fn [_]
+ (reset! menu-open* false)
+ (st/emit! (dwpr/exit-pull-request-preview))))
+
+ on-merge
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [_]
+ (reset! menu-open* false)
+ (confirm-merge! pr)))
+
+ on-play
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [_]
+ (reset! menu-open* false)
+ (st/emit! (dwpr/open-pull-request-viewer pr))))
+
+ on-publish
+ (mf/use-fn
+ (mf/deps pr)
+ (fn [_]
+ (reset! menu-open* false)
+ (st/emit! (dwpr/update-pull-request-snapshot (:id pr))
+ (dwpr/refresh-pull-request-preview-info (:id pr)))))]
+
+ (when (some? pr)
+ [:div {:class (stl/css-case :pr-banner true
+ :is-collapsed collapsed?)}
+ [:button {:class (stl/css :pr-banner-handle)
+ :title (tr (if collapsed?
+ "workspace.branches.banner.expand"
+ "workspace.branches.banner.collapse"))
+ :aria-label (tr (if collapsed?
+ "workspace.branches.banner.expand"
+ "workspace.branches.banner.collapse"))
+ :on-click on-toggle}
+ [:div {:class (stl/css :pr-banner-handle-btn)}]]
+ [:div {:class (stl/css :pr-banner-content)}
+ [:div {:class (stl/css :pr-banner-icon)}
+ [:> i/icon* {:icon-id i/git-pull-request-arrow}]]
+ [:div {:class (stl/css :pr-banner-text)}
+ [:span {:class (stl/css :pr-banner-title)}
+ (tr "workspace.pull-requests.banner.reviewing")]
+ [:span {:class (stl/css :pr-banner-name)} (:title pr)]
+ [:span {:class (stl/css :pr-banner-route)}
+ (dm/str (:branch-name pr) " → " (tr "workspace.branches.main"))]]
+
+ [:div {:class (stl/css :pr-banner-badges)}
+ [:> pr-state-badge* {:pr pr}]
+ (when (:outdated pr)
+ [:span {:class (stl/css :pr-badge :badge-outdated)}
+ (tr "workspace.pull-requests.badge.outdated")])
+ (when (pos? (or (:conflicts pr) 0))
+ [:span {:class (stl/css :pr-badge :badge-conflicts)}
+ (tr "workspace.pull-requests.badge.conflicts" (dm/str (:conflicts pr)))])
+ (when (pos? total)
+ [:span {:class (stl/css :pr-banner-approvals)}
+ [:> i/icon* {:icon-id i/tick :size "s"}]
+ (dm/str approvals "/" total)])]
+
+ [:div {:class (stl/css :pr-banner-actions)}
+ [:> button* {:variant "secondary"
+ :on-click on-info}
+ (tr "workspace.pull-requests.menu.info")]
+
+ [:> button* {:variant "secondary"
+ :icon i/switch
+ :on-click on-compare}
+ (tr "workspace.branches.compare")]
+
+ (when reviewer?
+ [:> button* {:variant "primary"
+ :icon i/tick
+ :on-click on-review}
+ (tr "workspace.pull-requests.banner.review")])
+
+ [:div {:class (stl/css :pr-banner-menu)}
+ [:> icon-button* {:variant "ghost"
+ :icon i/menu
+ :aria-label (tr "labels.options")
+ :on-click on-open-menu}]
+ [:> dropdown-menu* {:show menu-open?
+ :on-close on-close-menu
+ :class (stl/css :pr-banner-options-dropdown)}
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-play}
+ (tr "workspace.pull-requests.banner.play")]
+ (when (and author? (:outdated pr))
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-publish}
+ (tr "workspace.pull-requests.banner.publish")])
+ (when can-merge?
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-merge}
+ (tr "workspace.pull-requests.actions.merge")])
+ [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-exit}
+ (tr "workspace.pull-requests.banner.exit")]]]]]])))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/pull_requests.scss b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.scss
new file mode 100644
index 0000000000..cfc762468f
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.scss
@@ -0,0 +1,711 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC Sucursal en España SL
+
+@use "ds/_sizes.scss" as *;
+@use "ds/typography.scss" as t;
+@use "refactor/common-refactor.scss" as deprecated;
+
+// Same light-theme semantic-tint re-pointing the branches surfaces use
+// (see branches.scss for the rationale).
+.pr-section,
+.pr-banner,
+.modal-container {
+ :global(.light) & {
+ --color-foreground-success: var(--color-accent-success);
+ --color-foreground-error: var(--color-accent-error);
+ --color-foreground-warning: color-mix(in srgb, var(--color-accent-warning) 60%, #000);
+ --color-foreground-info: var(--color-accent-info);
+ }
+}
+
+// --- Sidebar section
+
+.pr-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+}
+
+.pr-section-header {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ padding-block: var(--sp-s) var(--sp-xs);
+ padding-inline: var(--sp-s);
+
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+
+ > button {
+ margin-inline-start: auto;
+ }
+}
+
+.pr-section-count {
+ color: var(--color-foreground-secondary);
+}
+
+.pr-section-empty {
+ @include t.use-typography("body-small");
+
+ padding-inline: var(--sp-s);
+ color: var(--color-foreground-secondary);
+}
+
+.pr-entries {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.pr-entry {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: start;
+ gap: var(--sp-s);
+ padding: var(--sp-s);
+ border-radius: 8px;
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--color-background-tertiary);
+ }
+}
+
+.pr-entry.is-finished {
+ opacity: 0.7;
+}
+
+.pr-entry.is-menu-open {
+ position: relative;
+ z-index: 1;
+}
+
+.pr-entry-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 32px;
+ block-size: 32px;
+ border-radius: 8px;
+ background-color: var(--color-background-tertiary);
+ color: var(--color-accent-primary);
+}
+
+.pr-entry-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+ min-width: 0;
+}
+
+.pr-entry-title {
+ @include t.use-typography("body-medium");
+
+ color: var(--color-foreground-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.pr-entry-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ min-width: 0;
+}
+
+.pr-entry-route {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.pr-entry-badges {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--sp-xs);
+}
+
+.pr-entry-aside {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+}
+
+.pr-entry-approvals {
+ @include t.use-typography("code-font");
+
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-xxs);
+ font-weight: 600;
+ color: var(--color-foreground-success);
+}
+
+.pr-options-dropdown {
+ @extend %menu-dropdown;
+
+ inset-block-start: 100%;
+ inset-inline-end: 0;
+ inline-size: $sz-160;
+}
+
+.menu-option {
+ @extend %menu-item-base;
+
+ white-space: nowrap;
+}
+
+.pr-finished {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+}
+
+.pr-finished-toggle {
+ @include t.use-typography("body-small");
+
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ padding-block: var(--sp-s) var(--sp-xs);
+ padding-inline: var(--sp-s);
+ border: none;
+ background: none;
+ cursor: pointer;
+ color: var(--color-foreground-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+}
+
+// --- State badges
+
+.pr-badge {
+ @include t.use-typography("body-small");
+
+ padding: var(--sp-xxs) var(--sp-s);
+ border-radius: 12px;
+ white-space: nowrap;
+}
+
+.badge-open {
+ color: var(--color-foreground-info);
+ background-color: var(--color-background-info);
+}
+
+.badge-in-review {
+ color: var(--color-foreground-info);
+ background-color: var(--color-background-info);
+}
+
+.badge-approved {
+ color: var(--color-foreground-success);
+ background-color: var(--color-background-success);
+}
+
+.badge-changes {
+ color: var(--color-foreground-warning);
+ background-color: var(--color-background-warning);
+}
+
+.badge-merged {
+ color: var(--color-foreground-success);
+ background-color: var(--color-background-success);
+}
+
+.badge-closed {
+ color: var(--color-foreground-secondary);
+ background-color: var(--color-background-tertiary);
+}
+
+.badge-outdated {
+ color: var(--color-foreground-warning);
+ background-color: var(--color-background-warning);
+}
+
+.badge-conflicts {
+ color: var(--color-foreground-error);
+ background-color: var(--color-background-error);
+}
+
+// --- Modals (create / review / details)
+
+.modal-overlay {
+ @extend %modal-overlay-base;
+}
+
+.modal-container {
+ @extend %modal-container-base;
+
+ min-width: deprecated.$s-408;
+
+ // long pull request descriptions: let the card grow past the base
+ // 512px cap up to the viewport, and scroll the CONTENT (header and
+ // footer stay pinned) instead of spilling out of the card
+ display: flex;
+ flex-direction: column;
+ max-block-size: min(90vh, 56rem);
+ max-inline-size: min(90vw, 40rem);
+ overflow: hidden;
+}
+
+.modal-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--sp-s);
+ margin-bottom: deprecated.$s-24;
+}
+
+.modal-header-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: $sz-32;
+ height: $sz-32;
+ border-radius: 8px;
+ color: var(--color-accent-primary);
+ background-color: var(--color-background-tertiary);
+}
+
+.modal-header-text {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+ flex: 1;
+}
+
+.modal-title {
+ @include deprecated.headline-medium-typography;
+
+ color: var(--modal-title-foreground-color);
+ overflow-wrap: anywhere;
+}
+
+.modal-subtitle {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+ overflow-wrap: anywhere;
+}
+
+.modal-subtitle-row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--sp-xs);
+}
+
+.modal-content {
+ @include deprecated.flex-column;
+
+ gap: deprecated.$s-16;
+ margin-bottom: deprecated.$s-24;
+ flex: 1 1 auto;
+ min-block-size: 0;
+ overflow-y: auto;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: var(--sp-s);
+
+ // action labels never wrap inside their button; the row wraps instead
+ button {
+ white-space: nowrap;
+ }
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+}
+
+.field-label {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+.textarea {
+ width: 100%;
+ min-height: $sz-64;
+ padding: var(--sp-s);
+ border-radius: 8px;
+ border: 1px solid var(--color-background-quaternary);
+ background-color: var(--color-background-tertiary);
+ color: var(--color-foreground-primary);
+ resize: vertical;
+
+ @include t.use-typography("body-medium");
+}
+
+// --- Reviewer picker (create dialog)
+
+.reviewers-empty {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+.reviewers-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ max-block-size: $sz-200;
+ overflow-y: auto;
+}
+
+.reviewer-option {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+ inline-size: 100%;
+ padding: var(--sp-xs) var(--sp-s);
+ border: 1px solid transparent;
+ border-radius: 8px;
+ background: none;
+ cursor: pointer;
+ color: var(--color-foreground-primary);
+
+ &:hover {
+ background-color: var(--color-background-tertiary);
+ }
+
+ &.is-selected {
+ border-color: var(--color-accent-primary);
+ background-color: var(--color-background-tertiary);
+ }
+
+ > svg:last-child {
+ margin-inline-start: auto;
+ color: var(--color-accent-primary);
+ }
+}
+
+.reviewer-name {
+ @include t.use-typography("body-medium");
+
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+// --- Review dialog (verdict options)
+
+.verdict-options {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-s);
+}
+
+.verdict-option {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-m);
+ padding: var(--sp-m);
+ border: 1px solid var(--color-background-quaternary);
+ border-radius: 8px;
+ background: none;
+ cursor: pointer;
+ text-align: start;
+ color: var(--color-foreground-primary);
+
+ &:hover {
+ background-color: var(--color-background-tertiary);
+ }
+
+ &.is-selected {
+ border-color: var(--color-accent-primary);
+ background-color: var(--color-background-tertiary);
+ }
+}
+
+.verdict-option-text {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+}
+
+.verdict-option-title {
+ @include t.use-typography("body-medium");
+
+ font-weight: 600;
+}
+
+.verdict-option-hint {
+ @include t.use-typography("body-small");
+
+ color: var(--color-foreground-secondary);
+}
+
+// --- Details dialog
+
+// the pull request metadata (author / dates / approvals / reviewers)
+// gets its own card, visually separated from the free-text description
+// (same surface treatment as the reviewer rows and the create dialog's
+// "derived from" box: tertiary background + 8px radius)
+.pr-info-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-m);
+ flex-shrink: 0;
+ padding: var(--sp-m);
+ border-radius: 8px;
+ border: 1px solid var(--color-background-quaternary);
+ background-color: var(--color-background-tertiary);
+}
+
+.pr-info-description-block {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+
+ // only the description text scrolls, never the whole modal: this
+ // block absorbs the remaining height of the card
+ flex: 1 1 auto;
+ min-block-size: 0;
+}
+
+.pr-info-description {
+ @include t.use-typography("body-medium");
+
+ margin: 0;
+ color: var(--color-foreground-primary);
+ white-space: pre-wrap;
+ overflow-y: auto;
+ padding-inline-end: var(--sp-s);
+
+ // user content: long words / urls must never push past the card
+ overflow-wrap: anywhere;
+}
+
+.pr-info-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--sp-m);
+}
+
+.pr-info-item {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xxs);
+}
+
+.pr-info-value {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+
+ @include t.use-typography("body-medium");
+
+ color: var(--color-foreground-primary);
+}
+
+.pr-info-reviewers {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ max-block-size: $sz-160;
+ overflow-y: auto;
+}
+
+.pr-info-reviewer {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--sp-s);
+ padding: var(--sp-xs) var(--sp-s);
+ border-radius: 8px;
+
+ // sits INSIDE the tertiary metadata card: step one surface down so
+ // the rows remain distinguishable
+ background-color: var(--color-background-primary);
+}
+
+.reviewer-state {
+ @include t.use-typography("body-small");
+
+ margin-inline-start: auto;
+ color: var(--color-foreground-secondary);
+}
+
+.reviewer-approved {
+ color: var(--color-foreground-success);
+}
+
+.reviewer-changes {
+ color: var(--color-foreground-warning);
+}
+
+.reviewer-stale {
+ color: var(--color-foreground-secondary);
+ font-style: italic;
+}
+
+.reviewer-comment {
+ @include t.use-typography("body-small");
+
+ flex-basis: 100%;
+ margin: 0;
+ color: var(--color-foreground-secondary);
+ white-space: pre-wrap;
+}
+
+// --- Review sandbox banner
+
+.pr-banner {
+ position: absolute;
+ inset-block-end: var(--sp-m);
+ inset-inline-start: 50%;
+ transform: translateX(-50%);
+ max-inline-size: 90vw;
+ border-radius: deprecated.$s-8;
+
+ // the review sandbox must never be confused with the editable branch:
+ // accent-colored border instead of the branch banner's neutral one
+ border: deprecated.$s-2 solid var(--color-accent-primary);
+ background-color: var(--color-background-primary);
+
+ // unlike the branch banner this one hosts a dropdown that opens
+ // upwards, outside the banner box: it must not be clipped (the
+ // collapsed state hides its content via block-size + visibility) and
+ // it must beat the viewport controls, which are raised to z-index 1
+ // while a drawing tool is active
+ overflow: visible;
+ z-index: deprecated.$z-index-2;
+ transition:
+ inset-block-end 0.2s,
+ block-size 0.2s;
+
+ &.is-collapsed {
+ inset-block-end: 0;
+ block-size: var(--sp-m);
+ border-radius: 12px 12px 0 0;
+
+ .pr-banner-content {
+ block-size: 0;
+ padding-block: 0;
+ visibility: hidden;
+ }
+ }
+}
+
+.pr-banner-handle {
+ @include deprecated.flex-center;
+ @include deprecated.button-style;
+
+ position: absolute;
+ inset-inline: 0;
+ inset-block-start: 0;
+ block-size: var(--sp-m);
+ inline-size: 100%;
+ z-index: 1;
+}
+
+.pr-banner-handle-btn {
+ block-size: var(--sp-xs);
+ inline-size: 100%;
+ max-inline-size: 64px;
+ padding: 0;
+ border-radius: var(--sp-xs);
+ background-color: var(--palette-handler-background-color);
+}
+
+.pr-banner-content {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-m);
+ padding: var(--sp-m) var(--sp-m) var(--sp-s);
+
+ // no overflow clip: the kebab dropdown opens upwards past the banner
+ // edge (the collapsed state hides this box via block-size + visibility)
+}
+
+.pr-banner-icon {
+ display: flex;
+ align-items: center;
+ color: var(--color-accent-primary);
+}
+
+.pr-banner-text {
+ display: flex;
+ align-items: baseline;
+ gap: var(--sp-xs);
+
+ @include t.use-typography("body-small");
+}
+
+.pr-banner-title,
+.pr-banner-route {
+ color: var(--color-foreground-secondary);
+}
+
+.pr-banner-name {
+ color: var(--color-foreground-primary);
+ font-weight: 700;
+}
+
+.pr-banner-badges {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+}
+
+.pr-banner-approvals {
+ @include t.use-typography("code-font");
+
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-xxs);
+ font-weight: 600;
+ color: var(--color-foreground-success);
+}
+
+.pr-banner-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-s);
+}
+
+.pr-banner-menu {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+// the banner sits at the bottom of the canvas, so its options menu
+// opens upwards
+.pr-banner-options-dropdown {
+ @extend %menu-dropdown;
+
+ inset-block-end: 100%;
+ inset-inline-end: 0;
+ inline-size: $sz-192;
+}
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index c38fce16ae..e6b7c6ff2a 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -276,6 +276,10 @@ msgstr "Add file"
msgid "dashboard.add-shared"
msgstr "Add as Shared Library"
+#: src/app/main/ui/dashboard/branches_popover.cljs:65
+msgid "dashboard.branches-badge"
+msgstr "%s branches"
+
#: src/app/main/ui/settings/profile.cljs:75
msgid "dashboard.change-email"
msgstr "Change email"
@@ -364,6 +368,10 @@ msgstr "Clear trash"
msgid "dashboard.copy-suffix"
msgstr "(copy)"
+#: src/app/main/ui/dashboard/file_menu.cljs:314
+msgid "dashboard.create-branch"
+msgstr "Create branch…"
+
#: src/app/main/ui/dashboard/organization_team_switch.cljs:223, src/app/main/ui/dashboard/organization_team_switch.cljs:304
msgid "dashboard.create-new-organization"
msgstr "Create new organization"
@@ -973,6 +981,10 @@ msgstr "Other teams"
msgid "dashboard.password-change"
msgstr "Change password"
+#: src/app/main/ui/dashboard/comments.cljs:131
+msgid "dashboard.pending-reviews.title"
+msgstr "Review requests"
+
#: src/app/main/data/common.cljs:194
msgid "dashboard.permissions-change.admin"
msgstr "You are now an admin on this team."
@@ -4522,6 +4534,11 @@ msgstr "Enter the name of the team"
msgid "onboarding.choice.team-up.create-team-name"
msgstr "Team name"
+#: src/app/main/ui/onboarding/team_choice.cljs:188
+#, unused
+msgid "onboarding.choice.team-up.create-team-placeholder"
+msgstr "Enter the name of the team"
+
#: src/app/main/ui/onboarding/team_choice.cljs:237
msgid "onboarding.choice.team-up.create-team-without-invite"
msgstr "Create team"
@@ -6287,6 +6304,679 @@ msgstr "Text Transform"
msgid "workspace.assets.ungroup"
msgstr "Ungroup"
+#: src/app/main/ui/workspace/sidebar/branches.cljs:836
+msgid "workspace.branches.archived"
+msgstr "Archived"
+
+#, unused
+msgid "workspace.branches.banner.collapse"
+msgstr "Collapse"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:518, src/app/main/ui/workspace/sidebar/branches.cljs:1614
+msgid "workspace.branches.banner.conflicts"
+msgstr "%s conflicts"
+
+#, unused
+msgid "workspace.branches.banner.expand"
+msgstr "Expand"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1596
+msgid "workspace.branches.banner.from-main"
+msgstr "from main"
+
+#, unused
+msgid "workspace.branches.banner.merged"
+msgstr "Merged branch"
+
+#, unused
+msgid "workspace.branches.banner.on-branch"
+msgstr "On branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1602
+msgid "workspace.branches.banner.view-main"
+msgstr "View in main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:529, src/app/main/ui/workspace/sidebar/branches.cljs:685, src/app/main/ui/workspace/sidebar/branches.cljs:1619
+msgid "workspace.branches.compare"
+msgstr "Compare"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:463
+#, unused
+msgid "workspace.branches.compare.attr"
+msgstr "Property"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:465
+#, unused
+msgid "workspace.branches.compare.branch"
+msgstr "branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1064
+msgid "workspace.branches.compare.empty"
+msgstr "No changes between this branch and main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1059
+msgid "workspace.branches.compare.error"
+msgstr "Could not load the changes of this branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1126
+msgid "workspace.branches.compare.export"
+msgstr "Export diff"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1118
+msgid "workspace.branches.compare.footer-changes"
+msgstr "%s changes"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1121
+msgid "workspace.branches.compare.footer-conflicts"
+msgstr "%s to resolve before merging"
+
+#, unused
+msgid "workspace.branches.compare.group.colors"
+msgstr "Colors"
+
+#, unused
+msgid "workspace.branches.compare.group.components"
+msgstr "Components"
+
+#, unused
+msgid "workspace.branches.compare.group.media"
+msgstr "Media"
+
+#, unused
+msgid "workspace.branches.compare.group.pages"
+msgstr "Pages"
+
+#, unused
+msgid "workspace.branches.compare.group.tokens"
+msgstr "Tokens"
+
+#, unused
+msgid "workspace.branches.compare.group.typographies"
+msgstr "Typographies"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1054, src/app/main/ui/workspace/sidebar/branches.cljs:1439
+msgid "workspace.branches.compare.loading"
+msgstr "Computing changes…"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:464
+#, unused
+msgid "workspace.branches.compare.main"
+msgstr "main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:220
+msgid "workspace.branches.compare.n-items"
+msgstr "%s items"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:221
+msgid "workspace.branches.compare.n-props"
+msgstr "%s properties"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:934
+msgid "workspace.branches.compare.no-props"
+msgstr "No property-level changes"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:924
+msgid "workspace.branches.compare.prop-changes"
+msgstr "Property changes"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:912
+msgid "workspace.branches.compare.select-hint"
+msgstr "Select a change to see its details"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:140
+#, unused
+msgid "workspace.branches.compare.shape.board"
+msgstr "Board"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:147
+#, unused
+msgid "workspace.branches.compare.shape.bool"
+msgstr "Boolean"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:148
+#, unused
+msgid "workspace.branches.compare.shape.component"
+msgstr "Component"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:149
+#, unused
+msgid "workspace.branches.compare.shape.component-copy"
+msgstr "Component copy"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:142
+#, unused
+msgid "workspace.branches.compare.shape.ellipse"
+msgstr "Ellipse"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:146
+#, unused
+msgid "workspace.branches.compare.shape.group"
+msgstr "Group"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:145
+#, unused
+msgid "workspace.branches.compare.shape.image"
+msgstr "Image"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:143
+#, unused
+msgid "workspace.branches.compare.shape.path"
+msgstr "Path"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:141
+#, unused
+msgid "workspace.branches.compare.shape.rect"
+msgstr "Rectangle"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:144
+#, unused
+msgid "workspace.branches.compare.shape.text"
+msgstr "Text"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:150
+#, unused
+msgid "workspace.branches.compare.shape.variant"
+msgstr "Variant"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1036
+msgid "workspace.branches.compare.stats.added"
+msgstr "added"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1043
+msgid "workspace.branches.compare.stats.conflicts"
+msgstr "conflicts"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1040
+msgid "workspace.branches.compare.stats.deleted"
+msgstr "deleted"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1038
+msgid "workspace.branches.compare.stats.modified"
+msgstr "modified"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:879
+msgid "workspace.branches.compare.subtitle-mods"
+msgstr "%s property changes"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:876
+msgid "workspace.branches.compare.subtitle-new"
+msgstr "New %s"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1013, src/app/main/ui/workspace/sidebar/branches.cljs:1014, src/app/main/ui/workspace/sidebar/branches.cljs:1025, src/app/main/ui/workspace/sidebar/branches.cljs:1026
+msgid "workspace.branches.compare.swap"
+msgstr "Swap direction (branch ↔ main)"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1005
+msgid "workspace.branches.compare.title"
+msgstr "Compare changes"
+
+#, unused
+msgid "workspace.branches.compare.type.color"
+msgstr "Color"
+
+#, unused
+msgid "workspace.branches.compare.type.component"
+msgstr "Component"
+
+#, unused
+msgid "workspace.branches.compare.type.media"
+msgstr "Media"
+
+#, unused
+msgid "workspace.branches.compare.type.shape"
+msgstr "Shape"
+
+#, unused
+msgid "workspace.branches.compare.type.token"
+msgstr "Token"
+
+#, unused
+msgid "workspace.branches.compare.type.typography"
+msgstr "Typography"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1430
+msgid "workspace.branches.conflicts.all-branch"
+msgstr "Use branch for all"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1428
+msgid "workspace.branches.conflicts.all-main"
+msgstr "Use main for all"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1534
+msgid "workspace.branches.conflicts.apply"
+msgstr "Apply and merge"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1426
+msgid "workspace.branches.conflicts.bulk-label"
+msgstr "Bulk actions:"
+
+#, unused
+msgid "workspace.branches.conflicts.card.base"
+msgstr "Base (ancestor)"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1369
+msgid "workspace.branches.conflicts.card.base-meta"
+msgstr "at branch creation"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1497
+msgid "workspace.branches.conflicts.card.branch"
+msgstr "Your branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1495
+msgid "workspace.branches.conflicts.card.main"
+msgstr "Main"
+
+#, unused
+msgid "workspace.branches.conflicts.card.result"
+msgstr "Result"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1372
+msgid "workspace.branches.conflicts.card.you"
+msgstr "You"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:992
+#, unused
+msgid "workspace.branches.conflicts.chosen"
+msgstr "Chosen"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:777
+#, unused
+msgid "workspace.branches.conflicts.chosen-branch"
+msgstr "branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:775
+#, unused
+msgid "workspace.branches.conflicts.chosen-main"
+msgstr "main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1444
+msgid "workspace.branches.conflicts.empty"
+msgstr "No conflicts to resolve"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1520
+msgid "workspace.branches.conflicts.footer-pending"
+msgstr "%s conflict(s) pending · resolve all to merge"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1515
+msgid "workspace.branches.conflicts.footer-ready"
+msgstr "All conflicts resolved"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1450
+msgid "workspace.branches.conflicts.list-head"
+msgstr "%s conflicts"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1433
+msgid "workspace.branches.conflicts.pending"
+msgstr "%s pending"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1419
+msgid "workspace.branches.conflicts.progress"
+msgstr "%s of %s resolved"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1493
+msgid "workspace.branches.conflicts.prop-header"
+msgstr "Property"
+
+#, unused
+msgid "workspace.branches.conflicts.reason.add-add"
+msgstr "added in both with different values"
+
+#, unused
+msgid "workspace.branches.conflicts.reason.delete-modify"
+msgstr "deleted in the branch, edited in main"
+
+#, unused
+msgid "workspace.branches.conflicts.reason.modify-delete"
+msgstr "edited in the branch, deleted in main"
+
+#, unused
+msgid "workspace.branches.conflicts.reason.modify-modify"
+msgstr "edited in main and in the branch since the base"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1134, src/app/main/ui/workspace/sidebar/branches.cljs:1626
+msgid "workspace.branches.conflicts.resolve"
+msgstr "Resolve conflicts"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1198
+msgid "workspace.branches.conflicts.side-branch"
+msgstr "branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1194
+msgid "workspace.branches.conflicts.side-main"
+msgstr "main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1190
+msgid "workspace.branches.conflicts.side-mixed"
+msgstr "mixed"
+
+#, unused
+msgid "workspace.branches.conflicts.state-pending"
+msgstr "unresolved"
+
+#, unused
+msgid "workspace.branches.conflicts.state-resolved"
+msgstr "resolved"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1417
+msgid "workspace.branches.conflicts.subtitle"
+msgstr "Merge %s into main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1415
+msgid "workspace.branches.conflicts.title"
+msgstr "Resolve conflicts"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1477
+msgid "workspace.branches.conflicts.use-branch"
+msgstr "Use branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1474
+msgid "workspace.branches.conflicts.use-main"
+msgstr "Use main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:994
+#, unused
+msgid "workspace.branches.conflicts.use-this"
+msgstr "Use this"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1508
+msgid "workspace.branches.conflicts.whole-hint"
+msgstr "This element was added or removed on one side. Choose which version to keep."
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:299
+msgid "workspace.branches.create.derive-from"
+msgstr "Derive from"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:305
+msgid "workspace.branches.create.derive-from-latest"
+msgstr "latest version"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:303
+msgid "workspace.branches.create.derive-from-main"
+msgstr "main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:291
+msgid "workspace.branches.create.description-label"
+msgstr "Description (optional)"
+
+#: src/app/main/data/workspace/branches.cljs:208
+msgid "workspace.branches.create.error"
+msgstr "Could not create the branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:308
+msgid "workspace.branches.create.info"
+msgstr ""
+"Your branch is a complete, isolated copy. Nothing you do affects main until "
+"you merge it."
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:283
+msgid "workspace.branches.create.name-label"
+msgstr "Branch name"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:285
+msgid "workspace.branches.create.name-placeholder"
+msgstr "e.g. redesign-checkout"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:317
+msgid "workspace.branches.create.submit"
+msgstr "Create branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:276
+msgid "workspace.branches.create.subtitle"
+msgstr "Derived from main · %s"
+
+#: src/app/main/data/workspace/branches.cljs:201
+msgid "workspace.branches.create.success"
+msgstr "Branch \"%s\" created"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:274
+msgid "workspace.branches.create.title"
+msgstr "Create branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:629
+msgid "workspace.branches.delete.message"
+msgstr "Delete branch \"%s\"? This cannot be undone."
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:628
+msgid "workspace.branches.delete.title"
+msgstr "Delete branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:356
+#, unused
+msgid "workspace.branches.empty"
+msgstr "No branches yet"
+
+#: src/app/main/ui/workspace/left_header.cljs:169
+msgid "workspace.branches.header-badge-tooltip"
+msgstr "On branch %s from main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:485
+msgid "workspace.branches.info.author"
+msgstr "Created by"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:510
+msgid "workspace.branches.info.changes"
+msgstr "Changes"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:498
+msgid "workspace.branches.info.created"
+msgstr "Created"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:491
+msgid "workspace.branches.info.derived-from"
+msgstr "Derived from"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:464
+msgid "workspace.branches.info.description"
+msgstr "Description"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:479
+msgid "workspace.branches.info.description-empty"
+msgstr "No description"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:474
+msgid "workspace.branches.info.description-placeholder"
+msgstr "Describe the purpose of this branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:522
+msgid "workspace.branches.info.merged"
+msgstr "Merged"
+
+#, unused
+msgid "workspace.branches.info.status.archived"
+msgstr "Archived"
+
+#, unused
+msgid "workspace.branches.info.status.merged"
+msgstr "Merged"
+
+#, unused
+msgid "workspace.branches.info.status.open"
+msgstr "Open"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:445
+msgid "workspace.branches.info.title"
+msgstr "Branch info"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:504
+msgid "workspace.branches.info.updated"
+msgstr "Last updated"
+
+#: src/app/main/data/workspace/branches.cljs:158
+msgid "workspace.branches.lifecycle.deleted"
+msgstr "Branch deleted"
+
+#: src/app/main/data/workspace/branches.cljs:126, src/app/main/data/workspace/branches.cljs:138, src/app/main/data/workspace/branches.cljs:149, src/app/main/data/workspace/branches.cljs:160
+msgid "workspace.branches.lifecycle.error"
+msgstr "The branch operation could not be completed"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:799
+msgid "workspace.branches.loading"
+msgstr "Loading branches…"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:337, src/app/main/ui/workspace/sidebar/branches.cljs:494, src/app/main/ui/workspace/sidebar/branches.cljs:723, src/app/main/ui/workspace/sidebar/branches.cljs:760, src/app/main/ui/workspace/sidebar/branches.cljs:1011, src/app/main/ui/workspace/sidebar/branches.cljs:1031
+msgid "workspace.branches.main"
+msgstr "Main"
+
+#: src/app/main/data/workspace/branches.cljs:336
+msgid "workspace.branches.main-advanced"
+msgstr "main has new changes — you can update this branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:659
+msgid "workspace.branches.main-subtitle"
+msgstr "Default branch"
+
+#: src/app/main/ui/workspace/main_menu.cljs:736
+msgid "workspace.branches.menu"
+msgstr "Branches"
+
+#, unused
+msgid "workspace.branches.menu.archive"
+msgstr "Archive"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:682
+msgid "workspace.branches.menu.info"
+msgstr "Branch info"
+
+#, unused
+msgid "workspace.branches.menu.restore"
+msgstr "Restore"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:1148, src/app/main/ui/workspace/sidebar/branches.cljs:1638
+msgid "workspace.branches.merge.action"
+msgstr "Merge into main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:370, src/app/main/ui/workspace/sidebar/branches.cljs:1526
+msgid "workspace.branches.merge.archive-label"
+msgstr "Archive this branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:376
+msgid "workspace.branches.merge.archive-warning"
+msgstr ""
+"Archived branches keep a full copy of the file so they can be restored "
+"later. Keeping many archived branches can take up a lot of storage."
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:386
+msgid "workspace.branches.merge.confirm-accept"
+msgstr "Merge"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:335
+msgid "workspace.branches.merge.confirm-message"
+msgstr ""
+"Merge %s into %s? This applies the branch's changes to main and can't be "
+"undone."
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:356
+msgid "workspace.branches.merge.confirm-title"
+msgstr "Merge into main"
+
+#: src/app/main/data/workspace/branches.cljs:415
+#, unused
+msgid "workspace.branches.merge.conflicts"
+msgstr "This branch has conflicts that must be resolved before merging"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:378
+msgid "workspace.branches.merge.delete-info"
+msgstr ""
+"After merging, this branch and its file will be deleted to free up storage. "
+"Archive it if you might need to restore it later."
+
+#: src/app/main/data/workspace/branches.cljs:451, src/app/main/data/workspace/branches.cljs:459
+msgid "workspace.branches.merge.error"
+msgstr "Could not merge the branch"
+
+#: src/app/main/data/workspace/branches.cljs:457
+msgid "workspace.branches.merge.main-moved"
+msgstr ""
+"Main changed while you were reviewing; the comparison has been reloaded, "
+"please review and retry"
+
+#: src/app/main/data/workspace/branches.cljs:447
+msgid "workspace.branches.merge.success"
+msgstr "Branch merged into main"
+
+#: src/app/main/data/workspace/branches.cljs:450
+msgid "workspace.branches.merge.unsupported"
+msgstr ""
+"This branch changes elements that can't be merged automatically yet "
+"(components, pages or tokens)"
+
+#: src/app/main/ui/dashboard/branches_popover.cljs:84, src/app/main/ui/workspace/sidebar/branches.cljs:793
+msgid "workspace.branches.new"
+msgstr "New"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:787
+msgid "workspace.branches.search.placeholder"
+msgstr "Search branch…"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:805
+msgid "workspace.branches.section.current"
+msgstr "Current branch"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:816
+msgid "workspace.branches.section.others"
+msgstr "Other branches"
+
+#, unused
+msgid "workspace.branches.status.added"
+msgstr "Added"
+
+#, unused
+msgid "workspace.branches.status.conflict"
+msgstr "Conflict"
+
+#, unused
+msgid "workspace.branches.status.deleted"
+msgstr "Deleted"
+
+#, unused
+msgid "workspace.branches.status.modified"
+msgstr "Modified"
+
+#: src/app/main/ui/workspace/sidebar.cljs:261
+msgid "workspace.branches.tab"
+msgstr "Branches"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:688, src/app/main/ui/workspace/sidebar/branches.cljs:1141, src/app/main/ui/workspace/sidebar/branches.cljs:1632
+msgid "workspace.branches.update"
+msgstr "Update from main"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:403
+msgid "workspace.branches.update.confirm-accept"
+msgstr "Update"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:402
+msgid "workspace.branches.update.confirm-message"
+msgstr ""
+"Update \"%s\" with the latest changes from main? This may overwrite "
+"conflicting changes on the branch."
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:401
+msgid "workspace.branches.update.confirm-title"
+msgstr "Update from main"
+
+#, unused
+msgid "workspace.branches.update.conflicts"
+msgstr "main has conflicting changes; resolve them when merging"
+
+#: src/app/main/data/workspace/branches.cljs:362, src/app/main/data/workspace/branches.cljs:364
+msgid "workspace.branches.update.error"
+msgstr "Could not update the branch"
+
+#: src/app/main/data/workspace/branches.cljs:358
+msgid "workspace.branches.update.success"
+msgstr "Branch updated from main"
+
+#: src/app/main/data/workspace/branches.cljs:361
+msgid "workspace.branches.update.unsupported"
+msgstr ""
+"main changed elements that can't be brought in automatically yet "
+"(components, pages or token structure)"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:439
+#, unused
+msgid "workspace.branches.you-editing"
+msgstr "%s · editing now"
+
#: src/app/main/data/workspace/clipboard.cljs:1170
msgid "workspace.clipboard.copying"
msgstr "Copying image…"
@@ -8255,6 +8945,293 @@ msgstr "'%s' PLUGIN IS INSTALLED FOR YOUR USER!"
msgid "workspace.plugins.try-out.try"
msgstr "TRY PLUGIN"
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:426, src/app/main/ui/workspace/sidebar/pull_requests.cljs:505, src/app/main/ui/workspace/sidebar/pull_requests.cljs:564
+msgid "workspace.pull-requests.actions.close"
+msgstr "Cancel pull request"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:440, src/app/main/ui/workspace/sidebar/pull_requests.cljs:561, src/app/main/ui/workspace/sidebar/pull_requests.cljs:754
+msgid "workspace.pull-requests.actions.merge"
+msgstr "Merge to main"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:435
+msgid "workspace.pull-requests.actions.open-review"
+msgstr "Open review"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:430, src/app/main/ui/workspace/sidebar/pull_requests.cljs:567
+msgid "workspace.pull-requests.actions.reopen"
+msgstr "Reopen pull request"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:359, src/app/main/ui/workspace/sidebar/pull_requests.cljs:537, src/app/main/ui/workspace/sidebar/pull_requests.cljs:722
+msgid "workspace.pull-requests.badge.conflicts"
+msgstr "%s conflicts"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:356, src/app/main/ui/workspace/sidebar/pull_requests.cljs:534, src/app/main/ui/workspace/sidebar/pull_requests.cljs:719
+msgid "workspace.pull-requests.badge.outdated"
+msgstr "New changes on branch"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:759
+msgid "workspace.pull-requests.banner.exit"
+msgstr "Exit review"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:736
+msgid "workspace.pull-requests.banner.play"
+msgstr "Try interactions"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:742
+msgid "workspace.pull-requests.banner.publish"
+msgstr "Publish latest changes"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:748
+msgid "workspace.pull-requests.banner.review"
+msgstr "Review"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:710
+msgid "workspace.pull-requests.banner.reviewing"
+msgstr "Reviewing pull request"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:504
+msgid "workspace.pull-requests.close.message"
+msgstr ""
+"The review of \"%s\" will be cancelled. The branch is not deleted: it "
+"goes back to being a normal branch."
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:503
+msgid "workspace.pull-requests.close.title"
+msgstr "Cancel pull request"
+
+#: src/app/main/data/workspace/pull_requests.cljs:142, src/app/main/data/workspace/pull_requests.cljs:255
+msgid "workspace.pull-requests.create.already-exists"
+msgstr "This branch already has an open pull request"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:167
+msgid "workspace.pull-requests.create.description-label"
+msgstr "Description"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:170
+msgid "workspace.pull-requests.create.description-placeholder"
+msgstr "What changed, and what needs review or validation?"
+
+#: src/app/main/data/workspace/pull_requests.cljs:143
+msgid "workspace.pull-requests.create.error"
+msgstr "An error occurred while creating the pull request"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:193
+msgid "workspace.pull-requests.create.info"
+msgstr ""
+"Reviewers will get a read-only sandbox with the branch state at this "
+"moment. You can keep working on the branch and publish newer changes at any "
+"time."
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:159
+msgid "workspace.pull-requests.create.name-label"
+msgstr "Title"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:161
+msgid "workspace.pull-requests.create.name-placeholder"
+msgstr "What is this review about?"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:179
+msgid "workspace.pull-requests.create.no-members"
+msgstr "There are no other team members to request a review from"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:176
+msgid "workspace.pull-requests.create.reviewers-label"
+msgstr "Reviewers"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:202
+msgid "workspace.pull-requests.create.submit"
+msgstr "Create pull request"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:152
+msgid "workspace.pull-requests.create.subtitle"
+msgstr "Request a review of the changes on \"%s\""
+
+#: src/app/main/data/workspace/pull_requests.cljs:137
+msgid "workspace.pull-requests.create.success"
+msgstr "Pull request \"%s\" created"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:150
+msgid "workspace.pull-requests.create.title"
+msgstr "Create pull request"
+
+#: src/app/main/ui/workspace/left_header.cljs:163
+msgid "workspace.pull-requests.header-badge-tooltip"
+msgstr "Reviewing pull request: %s"
+
+#: src/app/main/ui/workspace/left_header.cljs:54
+msgid "workspace.pull-requests.header-title"
+msgstr "Review: %s"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:385, src/app/main/ui/workspace/sidebar/pull_requests.cljs:542
+msgid "workspace.pull-requests.info.approvals"
+msgstr "Approvals"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:369
+msgid "workspace.pull-requests.info.author"
+msgstr "Author"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:375
+msgid "workspace.pull-requests.info.created-at"
+msgstr "Created"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:364
+msgid "workspace.pull-requests.info.description"
+msgstr "Description"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:392
+msgid "workspace.pull-requests.info.reviewers"
+msgstr "Reviewers"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:380
+msgid "workspace.pull-requests.info.updated-at"
+msgstr "Review updated"
+
+#: src/app/main/data/workspace/pull_requests.cljs:252
+msgid "workspace.pull-requests.lifecycle.branch-gone"
+msgstr "The pull request's branch is no longer open"
+
+#: src/app/main/data/workspace/pull_requests.cljs:230
+msgid "workspace.pull-requests.lifecycle.closed"
+msgstr "Pull request closed"
+
+#: src/app/main/data/workspace/pull_requests.cljs:160, src/app/main/data/workspace/pull_requests.cljs:187, src/app/main/data/workspace/pull_requests.cljs:213, src/app/main/data/workspace/pull_requests.cljs:237, src/app/main/data/workspace/pull_requests.cljs:257
+msgid "workspace.pull-requests.lifecycle.error"
+msgstr "An error occurred while updating the pull request"
+
+#: src/app/main/data/workspace/pull_requests.cljs:246
+msgid "workspace.pull-requests.lifecycle.reopened"
+msgstr "Pull request reopened"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:555, src/app/main/ui/workspace/sidebar/pull_requests.cljs:731
+msgid "workspace.pull-requests.menu.info"
+msgstr "Details"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:558
+msgid "workspace.pull-requests.menu.publish"
+msgstr "Publish latest changes"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:691
+msgid "workspace.pull-requests.menu.request-review"
+msgstr "Request review"
+
+#: src/app/main/ui/workspace/sidebar/branches.cljs:700
+msgid "workspace.pull-requests.menu.review-details"
+msgstr "Review details"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:618
+msgid "workspace.pull-requests.new"
+msgstr "New"
+
+#: src/app/main/data/workspace/pull_requests.cljs:396
+msgid "workspace.pull-requests.preview.unavailable"
+msgstr "The review sandbox is not available (the pull request may be closed)"
+
+#: src/app/main/data/workspace/pull_requests.cljs:182
+msgid "workspace.pull-requests.publish.success"
+msgstr "The reviewers now see the latest branch changes"
+
+#: src/app/main/data/workspace/pull_requests.cljs:183
+msgid "workspace.pull-requests.publish.up-to-date"
+msgstr "The review is already up to date with the branch"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:412
+msgid "workspace.pull-requests.review-state.approved"
+msgstr "Approved"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:415
+msgid "workspace.pull-requests.review-state.changes-requested"
+msgstr "Requested changes"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:418
+msgid "workspace.pull-requests.review-state.pending"
+msgstr "Pending"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:409
+msgid "workspace.pull-requests.review-state.stale"
+msgstr "Reviewed an earlier version"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:258
+msgid "workspace.pull-requests.review.approve"
+msgstr "Approve"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:260
+msgid "workspace.pull-requests.review.approve-hint"
+msgstr "The changes look good to you"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:275
+msgid "workspace.pull-requests.review.comment-label"
+msgstr "Comment (optional)"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:278
+msgid "workspace.pull-requests.review.comment-placeholder"
+msgstr "Share your feedback with the author"
+
+#: src/app/main/data/workspace/pull_requests.cljs:212
+msgid "workspace.pull-requests.review.not-a-reviewer"
+msgstr "Only assigned reviewers can submit a review"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:269
+msgid "workspace.pull-requests.review.request-changes"
+msgstr "Request changes"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:271
+msgid "workspace.pull-requests.review.request-changes-hint"
+msgstr "Something needs to be fixed before merging"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:287
+msgid "workspace.pull-requests.review.submit"
+msgstr "Submit review"
+
+#: src/app/main/data/workspace/pull_requests.cljs:206
+msgid "workspace.pull-requests.review.submitted"
+msgstr "Review submitted"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:242
+msgid "workspace.pull-requests.review.title"
+msgstr "Submit review"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:632
+msgid "workspace.pull-requests.section.empty"
+msgstr "No pull requests yet. Create one from a branch to request a review."
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:639
+msgid "workspace.pull-requests.section.history"
+msgstr "Finished"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:611
+msgid "workspace.pull-requests.section.title"
+msgstr "Pull requests"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:100
+#, unused
+msgid "workspace.pull-requests.state.approved"
+msgstr "Approved"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:100
+#, unused
+msgid "workspace.pull-requests.state.changes-requested"
+msgstr "Changes requested"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:100
+#, unused
+msgid "workspace.pull-requests.state.closed"
+msgstr "Closed"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:100
+#, unused
+msgid "workspace.pull-requests.state.in-review"
+msgstr "In review"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:100
+#, unused
+msgid "workspace.pull-requests.state.merged"
+msgstr "Merged"
+
+#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:100
+#, unused
+msgid "workspace.pull-requests.state.open"
+msgstr "Open"
+
#: src/app/main/ui/workspace/context_menu.cljs:659
msgid "workspace.shape.menu.add-flex"
msgstr "Add flex layout"
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000000..d63174f092
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2311 @@
+{
+ "name": "penpot",
+ "version": "1.20.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "penpot",
+ "version": "1.20.0",
+ "license": "MPL-2.0",
+ "devDependencies": {
+ "@types/node": "^25.9.2",
+ "esbuild": "^0.28.0",
+ "mdts": "^0.20.3",
+ "nrepl-client": "^0.3.0",
+ "opencode-ai": "^1.17.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@kwsites/file-exists": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
+ "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.1.1"
+ }
+ },
+ "node_modules/@kwsites/promise-deferred": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
+ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
+ "dev": true
+ },
+ "node_modules/@simple-git/args-pathspec": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz",
+ "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==",
+ "dev": true
+ },
+ "node_modules/@simple-git/argv-parser": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz",
+ "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==",
+ "dev": true,
+ "dependencies": {
+ "@simple-git/args-pathspec": "^1.0.3"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.9.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz",
+ "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "dev": true,
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/bencode": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/bencode/-/bencode-2.0.3.tgz",
+ "integrity": "sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==",
+ "dev": true
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "dev": true,
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/bundle-name": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+ "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+ "dev": true,
+ "dependencies": {
+ "run-applescript": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "dev": true,
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "dev": true,
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/default-browser": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
+ "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
+ "dev": true,
+ "dependencies": {
+ "bundle-name": "^4.1.0",
+ "default-browser-id": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/default-browser-id": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+ "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/execa": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz",
+ "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.0",
+ "get-stream": "^5.0.0",
+ "human-signals": "^1.1.1",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.0",
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "dev": true,
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "dev": true,
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/github-slugger": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
+ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
+ "dev": true
+ },
+ "node_modules/glob": {
+ "version": "13.0.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
+ "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+ "dev": true,
+ "dependencies": {
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "dev": true,
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
+ "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.12.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "dev": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+ "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+ "dev": true,
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-in-ssh": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
+ "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
+ "dev": true,
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+ "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+ "dev": true,
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "dev": true
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+ "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+ "dev": true,
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "node_modules/linkify-it": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
+ "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "dev": true,
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/markdown-it": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
+ "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.1",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mdts": {
+ "version": "0.20.3",
+ "resolved": "https://registry.npmjs.org/mdts/-/mdts-0.20.3.tgz",
+ "integrity": "sha512-XFLIyc9ugFru6+LX7FmaadY6N6WbY9wll5PsemE9DVC/v1hy6iH/oYx/i+aGXe8wv25ihgmS5/RWOFlHMHZopQ==",
+ "dev": true,
+ "dependencies": {
+ "chalk": "^5.4.1",
+ "chokidar": "^4.0.3",
+ "commander": "^14.0.0",
+ "express": "^5.1.0",
+ "github-slugger": "^2.0.0",
+ "glob": "^13.0.1",
+ "markdown-it": "^14.1.0",
+ "node-plantuml-back": "^1.1.4",
+ "open": "^11.0.0",
+ "plantuml": "^0.0.2",
+ "plantuml-encoder": "^1.4.0",
+ "simple-git": "^3.28.0",
+ "ws": "^8.18.3"
+ },
+ "bin": {
+ "mdts": "bin/mdts"
+ }
+ },
+ "node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "dev": true
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "dev": true,
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-nailgun-client": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/node-nailgun-client/-/node-nailgun-client-0.1.2.tgz",
+ "integrity": "sha512-OC611lR0fsDUSptwnhBf8d3sj4DZ5fiRKfS2QaGPe0kR3Dt9YoZr1MY7utK0scFPTbXuQdSBBbeoKYVbME1q5g==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^2.8.1"
+ },
+ "bin": {
+ "node-nailgun-client": "index.js"
+ }
+ },
+ "node_modules/node-nailgun-client/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/node-nailgun-server": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/node-nailgun-server/-/node-nailgun-server-0.3.0.tgz",
+ "integrity": "sha512-jiVQheazpL4ENevhIhw6wYKCb9UYuBTOQ0v85t2vMjur16CFPVK3fKQ3K8vSe3K6+72iI4aPpvm5g7dhRJtPfw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8.x"
+ }
+ },
+ "node_modules/node-plantuml-back": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/node-plantuml-back/-/node-plantuml-back-1.1.4.tgz",
+ "integrity": "sha512-HKgt2jctutB1EFax9UytlaBQYjb8oo3TOLzDCEj4Il0/kOzDo0jTae3V74sn3No9fgID4CU3azmBrq7hdM0+Jg==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^11.x",
+ "node-nailgun-client": "^0.1.2",
+ "node-nailgun-server": "^0.3.0",
+ "plantuml-encoder": "^1.4.0"
+ },
+ "bin": {
+ "puml": "index.js"
+ },
+ "engines": {
+ "node": ">= 6.x"
+ }
+ },
+ "node_modules/node-plantuml-back/node_modules/commander": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
+ "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/nrepl-client": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/nrepl-client/-/nrepl-client-0.3.0.tgz",
+ "integrity": "sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==",
+ "dev": true,
+ "dependencies": {
+ "bencode": "^2.0.1",
+ "tree-kill": "~1.2.1"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/open": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
+ "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
+ "dev": true,
+ "dependencies": {
+ "default-browser": "^5.4.0",
+ "define-lazy-prop": "^3.0.0",
+ "is-in-ssh": "^1.0.0",
+ "is-inside-container": "^1.0.0",
+ "powershell-utils": "^0.1.0",
+ "wsl-utils": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/opencode-ai": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.17.8.tgz",
+ "integrity": "sha512-NjivPiHsvbTXAX1rkeC2UpBhbYh/TW7AROWdOSOBYgtKFANovfwDVIJ7pHxGbsjnj2Uyd3nAOAGqZHqpXhkTrg==",
+ "cpu": [
+ "arm64",
+ "x64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "os": [
+ "darwin",
+ "linux",
+ "win32"
+ ],
+ "bin": {
+ "opencode": "bin/opencode.exe"
+ },
+ "optionalDependencies": {
+ "opencode-darwin-arm64": "1.17.8",
+ "opencode-darwin-x64": "1.17.8",
+ "opencode-darwin-x64-baseline": "1.17.8",
+ "opencode-linux-arm64": "1.17.8",
+ "opencode-linux-arm64-musl": "1.17.8",
+ "opencode-linux-x64": "1.17.8",
+ "opencode-linux-x64-baseline": "1.17.8",
+ "opencode-linux-x64-baseline-musl": "1.17.8",
+ "opencode-linux-x64-musl": "1.17.8",
+ "opencode-windows-arm64": "1.17.8",
+ "opencode-windows-x64": "1.17.8",
+ "opencode-windows-x64-baseline": "1.17.8"
+ }
+ },
+ "node_modules/opencode-darwin-arm64": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.17.8.tgz",
+ "integrity": "sha512-Ooav8WxwmgfgLXbEAAEhrjpJG6+s1K1NHb5UqkRwSokHY9wIIwsb1IfY+rgGmfa7wxm7UEEnBoa6nnNZwXjF1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/opencode-darwin-x64": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.17.8.tgz",
+ "integrity": "sha512-rUAv0hvOwVsnzGMA8/R5Jv2vq6tDb+KuaajumA4I4DFRNRMFr5wqYxKFH7131uPs48cO8oENMdquM3k4UEqxDg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/opencode-darwin-x64-baseline": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.17.8.tgz",
+ "integrity": "sha512-kHDhnNA1xnUaINo2WL3ydnVJh6GwPZ+bbH15CScICP8cUyotcwgldFKlk91AJZFwo/DdsI3DjjKTRpQVzd74+g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/opencode-linux-arm64": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.17.8.tgz",
+ "integrity": "sha512-Rnli+TvePnm/iEAIVoUTF8NBs9TgtdK1T9VyfEevmcq33nEkXw34dl3GSeDnmfaxxb6kpWaiOKMAiuMSVJG5uw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/opencode-linux-arm64-musl": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.17.8.tgz",
+ "integrity": "sha512-dZDMuk0mB4cJESYlRbvZqywrQWwYI5lr+izOgS5n4z3A1fjN0DWmRmVbUYjnBwSwxJD5dUCpoOme6vC7yIJ/gg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/opencode-linux-x64": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.17.8.tgz",
+ "integrity": "sha512-Sja8SHD5rFRGdbyndWJNZ3paJyHz/UJu+/QAK8hpbDxjzryb7u2fEF9o4UX6SUYcvDjgBrNdzoSWzCDwH9LxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/opencode-linux-x64-baseline": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.17.8.tgz",
+ "integrity": "sha512-2qwxKm6fusBIVENBmwAO3XGU04F8v9hxzI04oemFTze53SlkMc9Dw7KMMH7neHqNMVq6dnVCSkxK6Z697KrPGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/opencode-linux-x64-baseline-musl": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.17.8.tgz",
+ "integrity": "sha512-0+ms3Enj4KJN4W414EBy06+hkn/VehDSWHqzX2FRALnA/CW+FKDMOa1sSr4kykVQUPAGQjayiRD8Q7drmDaZlQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/opencode-linux-x64-musl": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.17.8.tgz",
+ "integrity": "sha512-FAeBaj5td6jJWClgB+Sj5W7i56OB9lxTXKeCL9JLEst/rsNNaHMwT1UR3UIQ55b6IGW3u2jg+in8k0Y62Ewofw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/opencode-windows-arm64": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.17.8.tgz",
+ "integrity": "sha512-ShXbqPGnyCV/Bj9mSWB4GdHLCdZQ7HQ0GNlKaag/91KqBpvNSBOQLBaAVi56iye1O6gZXso+hh34VjEyAA4Lhw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/opencode-windows-x64": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.17.8.tgz",
+ "integrity": "sha512-kAUfuhy8JLRKQ4Ks70GHIBv1qnPQbKBh1WAwkJyApu0ZeD9v+leJ2kNKtXpP1tS7OvNBURGVmO7XhlnHlocZLA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/opencode-windows-x64-baseline": {
+ "version": "1.17.8",
+ "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.17.8.tgz",
+ "integrity": "sha512-/6sUGzQbgLNyffpGz7N+unHZHhku+rrCNXqhs1hOG2YEAhBVlqX0gObnVLdj9KC9IbPRD7Ym0B8T8HXM2LNeWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/plantuml": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/plantuml/-/plantuml-0.0.2.tgz",
+ "integrity": "sha512-3YzQJUO1Yg+mDckTm3Ht5Q8bmtN8g3M9LD8fXqiqHDW3vzUpHrUe9lxVY6AT1I50w7FdOned0hhJno4JBIku2g==",
+ "dev": true,
+ "dependencies": {
+ "execa": "^4.0.0",
+ "get-stream": "^5.1.0"
+ }
+ },
+ "node_modules/plantuml-encoder": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/plantuml-encoder/-/plantuml-encoder-1.4.0.tgz",
+ "integrity": "sha512-sxMwpDw/ySY1WB2CE3+IdMuEcWibJ72DDOsXLkSmEaSzwEUaYBT6DWgOfBiHGCux4q433X6+OEFWjlVqp7gL6g==",
+ "dev": true
+ },
+ "node_modules/powershell-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
+ "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
+ "dev": true,
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "dev": true,
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "dev": true,
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/run-applescript": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+ "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "dev": true,
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true
+ },
+ "node_modules/simple-git": {
+ "version": "3.36.0",
+ "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz",
+ "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==",
+ "dev": true,
+ "dependencies": {
+ "@kwsites/file-exists": "^1.1.1",
+ "@kwsites/promise-deferred": "^1.1.1",
+ "@simple-git/args-pathspec": "^1.0.3",
+ "@simple-git/argv-parser": "^1.1.0",
+ "debug": "^4.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/steveukx/git-js?sponsor=1"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "dev": true,
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "dev": true
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wsl-utils": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
+ "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==",
+ "dev": true,
+ "dependencies": {
+ "is-wsl": "^3.1.0",
+ "powershell-utils": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}