✨ Add the design preview of file branching and pull requests

This commit carries the design-branching preview by Juan de la Cruz:
elhombretecla/penpot:branching at 0ac265a3f5, which holds 45 commits
and 3 develop merges and was last synced with develop on 2026-09-16.
The branch is squashed together with a merge of origin/develop at
7c039231f7, so the tree is the merged result.

The merge resolved conflicts in six files:

- backend/src/app/migrations.clj: develop's 0155 and 0156 steps run
  first, then the branching steps; the names differ.
- backend/src/app/rpc/commands/viewer.clj: both requires stay.
- common/src/app/common/flags.cljc: both flags stay.
- frontend/src/app/main/ui/dashboard/file_menu.cljs: "Create branch"
  becomes a menu-item* with the same id, guard and handler.
- frontend/src/app/main/ui/dashboard/grid.cljs: the portal goes with
  develop's menu rewrite; the branches popover stays in the trigger.
- frontend/translations/en.po: both sides' entries stay, with
  develop's source references.

Co-authored-by: Juan de la Cruz <delacruzgarciajuan@gmail.com>
AI-assisted-by: mixed models
This commit is contained in:
Álvaro Tejero Cantero 2026-10-04 13:54:28 +02:00
parent 8d8dff8de4
commit 2a5a235063
No known key found for this signature in database
72 changed files with 16403 additions and 52 deletions

View File

@ -0,0 +1,244 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-px-425 {
width: 425px !important;
max-width: 425px;
}
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="background-color:#E5E5E5;">
<div style="background-color:#E5E5E5;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
Hello {{name|abbreviate:25}}!</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
<span style="font-weight:bold;">{{ source-user }}</span> has requested your review on the pull request "{{ pull-request-title }}" ({{ pull-request-route }}).</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;font-style:italic;line-height:150%;text-align:left;color:#212426;
border-top: 1px solid #bfbfbf; border-bottom: 1px solid #bfbfbf; padding: 32px 0px;">
{{ pull-request-description }}
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#6911d4" role="presentation"
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
valign="middle">
<a href="{{ pull-request-url }}"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> REVIEW THE CHANGES </a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{% include "app/email/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -0,0 +1 @@
Review requested

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -50,6 +50,7 @@
inner join projects as pr on (f.project_id = pr.id)
where f.name ilike ('%' || ? || '%')
and (f.deleted_at is null)
and (f.is_branch is false)
order by f.created_at asc")
(defn search-files

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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/<name>`, 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/<name>`, 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/<title>`, 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.

View File

@ -0,0 +1,4 @@
{
"~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
"~:status": "closed"
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
{
"~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
"~:state": "approved",
"~:reviewed-revn": 11
}

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 185 B

View File

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

After

Width:  |  Height:  |  Size: 327 B

View File

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

After

Width:  |  Height:  |  Size: 254 B

View File

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

After

Width:  |  Height:  |  Size: 268 B

View File

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

After

Width:  |  Height:  |  Size: 268 B

View File

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

After

Width:  |  Height:  |  Size: 254 B

View File

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

After

Width:  |  Height:  |  Size: 312 B

View File

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

After

Width:  |  Height:  |  Size: 317 B

View File

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

After

Width:  |  Height:  |  Size: 286 B

View File

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

View File

@ -604,6 +604,7 @@
:workspace-editor-state
:workspace-wasm-editor-styles
:workspace-media-objects
:workspace-pr-preview
:workspace-presence
:workspace-tokens
:workspace-undo

View File

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

View File

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

View File

@ -0,0 +1,389 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS 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))))))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

2311
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff