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 }} +
+
+ + + + +
+ REVIEW THE CHANGES +
+
+
+ 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=<uuid>" 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"> + <path d="M2.667 8h10.666m0 0L8.667 3.333M13.333 8l-4.666 4.667"/> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> + <path d="M6 3v12"/> + <circle cx="18" cy="6" r="3"/> + <circle cx="6" cy="18" r="3"/> + <path d="M15 6a9 9 0 0 0-9 9"/> + <path d="M18 15v6"/> + <path d="M21 18h-6"/> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> + <path d="M15 6a9 9 0 0 0-9 9V3"/> + <circle cx="18" cy="6" r="3"/> + <circle cx="6" cy="18" r="3"/> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> + <line x1="12" x2="12" y1="3" y2="9"/> + <circle cx="12" cy="12" r="3"/> + <line x1="12" x2="12" y1="15" y2="21"/> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="12" cy="12" r="3"/> + <line x1="3" x2="9" y1="12" y2="12"/> + <line x1="15" x2="21" y1="12" y2="12"/> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="18" cy="18" r="3"/> + <circle cx="6" cy="6" r="3"/> + <path d="M6 21V9a9 9 0 0 0 9 9"/> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="5" cy="6" r="3"/> + <path d="M5 9v12"/> + <circle cx="19" cy="18" r="3"/> + <path d="m15 9-3-3 3-3"/> + <path d="M19 15V7a2 2 0 0 0-2-2h-5"/> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> + <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/> + <path d="m15 5 4 4"/> +</svg> 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> + <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/> + <path d="M12 9v4"/> + <path d="M12 17h.01"/> +</svg> 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 <row-or-nil>}`, 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 <row-or-nil>}; + ;; 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 <pr-row>}; 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 "<main name> (<branch name>)" 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" + } + } + } +}