✨ Redesign the branch compare and conflict dialogs

The compare and conflict dialogs listed raw entries with their raw
property values. They now read as a review: each change says what it
did in words, where it lives and what it affects, and only what truly
needs a choice asks for one.

Both dialogs move out of the branches sidebar namespace into
branch_review/ (layout, compare, conflicts, previews, facts and the
pure helpers in common) and share one frame: the route between the
branch and main, a bar of category filters, the list beside a detail
pane, and a footer. They size to the window within bounds, so the
panes and previews grow on large screens and stay usable on small ones.

Compare dialog:

- Changes are grouped by page and by top-level board, with decisions
  first and pages and library entries in groups of their own. A board
  folds its changes only when it holds two or more of the listed ones.
- Category filters (Decisions, Copy, Color, Layout, Components,
  Variants, Tokens, Other) are neutral chips with an icon; colour is
  kept for what happened to an entry: added, modified, deleted or in
  conflict.
- Each row carries a one-line summary of the kinds of change ("Fill
  changed", "Moved") and the detail shows the values, before and after.
- Previews show main and the branch side by side or overlaid, and an
  expanded view hands them the whole dialog. The side the open file
  lacks is rebuilt from the changed attributes; a side with nothing to
  draw says why (not in main, deleted, no preview).
- New and deleted pages and layers say what they hold and where they
  land; deleted components, colours, typographies and tokens count the
  layers of the open file that depend on them.
- Changes can be marked as reviewed while moving through the list.

Conflict dialog:

- A conflict compares each property with its base value. Properties
  that one side changed alone come filled in from that side, so only
  the real clashes wait for a choice, property by property.
- Text conflicts show the paragraphs each side keeps and drops, and
  entities that cannot be split offer the two versions to pick from.
- Main, branch and result previews and a note of what the decision
  drops sit beside the property table.

The compare buttons take the new git-compare-arrows icon.
This commit is contained in:
elhombretecla 2026-10-27 15:59:33 +01:00
parent f56da68b95
commit c68daed816
19 changed files with 4264 additions and 1740 deletions

View File

@ -404,12 +404,18 @@
:direction direction
:selected nil
;; a refetch of the SAME comparison keeps the user's
;; per-change selection (it names the same entities); a
;; different branch or direction starts over
;; per-change selection, review marks and conflict
;; choices (they name the same entities); a different
;; branch or direction starts over
:excluded (if (and (= branch-id (:branch-id prev))
(= direction (:direction prev)))
(or (:excluded prev) #{})
#{})})))
#{})
:reviewed (if (= branch-id (:branch-id prev))
(or (:reviewed prev) #{})
#{})
:resolutions (when (= branch-id (:branch-id prev))
(:resolutions prev))})))
ptk/WatchEvent
(watch [_ _ stream]
(->> (rp/cmd! :get-branch-diff {:branch-id branch-id :direction direction})
@ -449,6 +455,25 @@
(apply disj excluded change-keys)
(into excluded change-keys)))))))))
(defn toggle-diff-reviewed
"Mark the change `change-key` as reviewed in the compare dialog, or clear
the mark. `reviewed?` forces one side; nil flips it. The marks only
guide the review: the merge does not read them."
([change-key] (toggle-diff-reviewed change-key nil))
([change-key reviewed?]
(ptk/reify ::toggle-diff-reviewed
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-branch-diff :reviewed]
(fn [reviewed]
(let [reviewed (or reviewed #{})
reviewed? (if (some? reviewed?)
reviewed?
(not (contains? reviewed change-key)))]
(if reviewed?
(conj reviewed change-key)
(disj reviewed change-key)))))))))
(defn clear-diff-excluded
"Drop the per-change selection: the compare dialog closes without
applying it."
@ -630,6 +655,24 @@
(update [_ state]
(assoc-in state [:workspace-branch-diff :resolutions id] choice))))
(defn set-conflict-resolutions
"Store several conflict resolutions at once, `{entity-id resolution}`,
over the ones already chosen. A resolution is `:main`, `:branch` or a
per-attr `{attr side}` map."
[resolutions]
(ptk/reify ::set-conflict-resolutions
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-branch-diff :resolutions] merge resolutions))))
(defn clear-conflict-resolution
"Forget the choice made for conflict `id`, so it asks again."
[id]
(ptk/reify ::clear-conflict-resolution
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-branch-diff :resolutions] dissoc id))))
(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`

View File

@ -0,0 +1,659 @@
;; 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.branch-review.common
"What the compare and conflict dialogs say about a diff entry, kept free
of rendering: labels, values, categories, one-line summaries, the
page/board a change lives on, and how a conflict splits into the
properties that clash and the ones only one side touched."
(:require
[app.common.data.macros :as dm]
[app.common.time :as ct]
[app.common.types.text :as txt]
[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.ds.foundations.assets.icon :as i]
[app.util.i18n :as i18n :refer [tr]]
[cuerdas.core :as str]))
;; --- Merge / update confirmations
(defn confirm-merge!
"Open the merge dialog for `branch` (merge into main is irreversible; the
dialog also lets the user choose whether to keep the branch archived).
`excluded` is the compare dialog's per-change selection, if any."
([branch] (confirm-merge! branch nil))
([branch excluded]
(modal/show! :merge-branch {:branch branch :excluded excluded})))
(defn confirm-update!
"Ask for confirmation before updating `branch` from main (overwrites
conflicting branch changes). `excluded` is the compare dialog's
per-change selection, if any."
([branch] (confirm-update! branch nil))
([branch excluded]
(st/emit! (modal/show {:type :confirm
:title (tr "workspace.branches.update.confirm-title")
:message (tr "workspace.branches.update.confirm-message" (:name branch))
:accept-label (tr "workspace.branches.update.confirm-accept")
:accept-style :primary
:on-accept (fn [_] (st/emit! (dwb/update-branch-from-main branch
{:excluded excluded})))}))))
;; --- Icons and type labels
(def kind->icon
{:shape i/board
:component i/component
:color i/picker
:typography i/text
:media i/img
:page i/document
:page-attrs i/document
:page-order i/document
:page-guide i/document
:page-flow i/document
:page-grid i/document
:page-plugin i/document
:file-plugin i/document
:file-option i/document
:token i/tokens
:token-set i/tokens
:token-set-rename i/tokens
:token-set-order i/tokens
:token-theme i/tokens
:token-active-themes i/tokens
:token-active-sets i/tokens})
(def ^:private shape-type->icon
{:frame "board" :rect "rectangle" :line "rectangle" :circle "ellipse"
:path "path" :text "text" :image "img" :svg-raw "img" :group "group"
:bool "boolean-union"})
(defn shape-icon-id
"Type-accurate icon for a shape diff entry (component nature wins over
raw type), falling back to the board icon for unknown types."
[{:keys [shape-type component? component-copy? variant? masked?]}]
(cond
variant? "variant"
component? "component"
component-copy? "component-copy"
(and (= :group shape-type) masked?) "mask"
:else (get shape-type->icon shape-type "board")))
(defn entry-icon
[item]
(if (= :shape (:kind item))
(shape-icon-id item)
(get kind->icon (:kind item) i/git-branch)))
(def ^:private kind->type-label
{:shape "workspace.branches.compare.type.shape"
:component "workspace.branches.compare.type.component"
:color "workspace.branches.compare.type.color"
:typography "workspace.branches.compare.type.typography"
:media "workspace.branches.compare.type.media"
:token "workspace.branches.compare.type.token"
:page "workspace.branches.review.type.page"
:page-attrs "workspace.branches.review.type.page-settings"
:page-order "workspace.branches.review.type.page-order"
:page-guide "workspace.branches.review.type.guides"
:page-flow "workspace.branches.review.type.flows"
:page-grid "workspace.branches.review.type.grids"
:page-plugin "workspace.branches.review.type.plugin-data"
:file-plugin "workspace.branches.review.type.plugin-data"
:file-option "workspace.branches.review.type.file-options"
:token-set "workspace.branches.review.type.token-set"
:token-set-rename "workspace.branches.review.type.token-set"
:token-set-order "workspace.branches.review.type.token-set-order"
:token-theme "workspace.branches.review.type.theme"
:token-active-themes "workspace.branches.review.type.active-themes"
:token-active-sets "workspace.branches.review.type.active-sets"})
(def ^:private shape-type->label
{:frame "workspace.branches.compare.shape.board"
:rect "workspace.branches.compare.shape.rect"
:line "workspace.branches.compare.shape.rect"
:circle "workspace.branches.compare.shape.ellipse"
:path "workspace.branches.compare.shape.path"
:text "workspace.branches.compare.shape.text"
:image "workspace.branches.compare.shape.image"
:svg-raw "workspace.branches.compare.shape.image"
:group "workspace.branches.compare.shape.group"
:bool "workspace.branches.compare.shape.bool"})
(defn type-label-key
"i18n key for an item's specific type — type-accurate for shapes
(board/text/rectangle/component/…), generic for other kinds."
[item]
(if (= :shape (:kind item))
(let [{:keys [component? component-copy? variant? shape-type]} item]
(cond
variant? "workspace.branches.compare.shape.variant"
component? "workspace.branches.compare.shape.component"
component-copy? "workspace.branches.compare.shape.component-copy"
:else (get shape-type->label shape-type
"workspace.branches.compare.type.shape")))
(get kind->type-label (:kind item))))
;; --- Property labels and values
(defn attr-label
"Human-readable label for a changed property key. The named attrs are
the ones whose raw key means nothing to a user, so each carries a
catalogue entry. Every other attr keeps its own key, humanised
(kebab-case -> Capitalized words): a technical identifier rather than
prose, and the last resort for an attr the catalogue does not name."
[attr]
(case attr
(:rx :ry :r1 :r2 :r3 :r4) (tr "workspace.branches.compare.attr.corner-radius")
:content (tr "workspace.branches.review.attr.text")
:fills (tr "workspace.branches.review.attr.fill")
:strokes (tr "workspace.branches.review.attr.stroke")
:layout-gap (tr "workspace.branches.review.attr.gap")
:layout-padding (tr "workspace.branches.review.attr.padding")
:grow-type (tr "workspace.branches.compare.attr.text-auto-grow")
:blend-mode (tr "workspace.branches.compare.attr.blend-mode")
:shape-ref (tr "workspace.branches.compare.attr.component-reference")
:component-id (tr "workspace.branches.compare.attr.component")
:component-file (tr "workspace.branches.compare.attr.component-library")
:hidden (tr "workspace.branches.compare.attr.visibility")
:blocked (tr "workspace.branches.compare.attr.locked")
:proportion-lock (tr "workspace.branches.compare.attr.lock-proportions")
:parent-id (tr "workspace.branches.compare.attr.parent")
:frame-id (tr "workspace.branches.compare.attr.container")
(-> (name attr) (str/replace #"-" " ") str/capital)))
(defn hex-color
"Return a usable hex string when `v` looks like a color (a hex string, a
color map, or a fills/strokes vector), else nil."
[v]
(let [s (cond
(string? v) v
(and (map? v) (string? (:color v))) (:color v)
(and (map? v) (string? (:fill-color v))) (:fill-color v)
(and (map? v) (string? (:stroke-color v))) (:stroke-color v)
(and (sequential? v) (map? (first v)))
(or (:fill-color (first v)) (:stroke-color (first v)) (:color (first v)))
:else nil)]
;; only valid hex lengths (3/4/6/8 digits); a bare string must carry
;; the leading `#`, otherwise casual words like "cafe" become swatches
(when (and (string? s)
(re-matches #"#[0-9a-fA-F]{8}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3,4}" s))
s)))
(defn- fmt-number
"At most two decimals, trailing zeros stripped (89.99999 -> \"90\",
439.4999 -> \"439.5\", 386 -> \"386\"), grouped and punctuated the way
the active locale writes numbers (\"439,5\" in Spanish)."
[v]
(let [locale (-> i18n/*current-locale*
(str/replace "_" "-"))]
(.toLocaleString v locale #js {:maximumFractionDigits 2})))
(defn- short-str
[v]
(let [s (pr-str v)]
(if (> (count s) 40)
(str (subs s 0 40) "…")
s)))
(defn text-content?
"True for the value of a text layer's `:content` attr."
[v]
(and (map? v) (= "root" (:type v))))
(defn content-paragraphs
"The paragraphs of a text layer's content, as plain strings."
[content]
(if (text-content? content)
(str/split (txt/content->text content) "\n")
[]))
(defn display-val
"Compact, human-readable rendering of a property value. Raw uuids and
collections are summarized rather than dumped (they carry no meaning to
the user as raw data); a text layer's content reads as its text."
[v]
(cond
(nil? v) "—"
(text-content? v) (display-val (str/join " " (content-paragraphs v)))
(string? v) (if (> (count v) 32) (str (subs v 0 32) "…") v)
(number? v) (fmt-number v)
(boolean? v) (if v
(tr "workspace.branches.compare.value-true")
(tr "workspace.branches.compare.value-false"))
(keyword? v) (name v)
(ct/inst? v) (ct/format-inst v :localized-date-time)
(uuid? v) (str (subs (str v) 0 8) "…")
(sequential? v) (or (hex-color v)
(tr "workspace.branches.compare.n-items" (i18n/c (count v))))
(map? v) (or (hex-color v)
(tr "workspace.branches.compare.n-props" (i18n/c (count v))))
:else (short-str v)))
(def ^:private technical-attrs
"Attrs that say how the file stores a layer rather than how it looks.
They stay in the folded technical details instead of the property
table."
#{:id :parent-id :frame-id :shape-ref :component-id :component-file
:component-root :main-instance :touched :transform :transform-inverse
:selrect :points :position-data :plugin-data :applied-tokens :shapes
:remote-synced :variant-id :variant-name :variant-properties})
(defn visible-attrs
"The changed attrs a person reads: the ones that are not technical."
[changed-attrs]
(into [] (remove (fn [[attr _]] (contains? technical-attrs attr))) changed-attrs))
;; --- Categories
(def ^:private copy-attrs #{:content :name})
(def ^:private colour-attrs #{:fills :strokes :fill-color :stroke-color :color :gradient
:shadow :opacity :blend-mode})
(def ^:private layout-attrs #{:layout :layout-flex-dir :layout-gap :layout-padding
:layout-padding-type :layout-gap-type :layout-wrap-type
:layout-justify-content :layout-align-items
:layout-align-content :layout-justify-items
:layout-grid-rows :layout-grid-columns :layout-grid-cells
:layout-item-margin :layout-item-h-sizing
:layout-item-v-sizing :layout-item-align-self
:x :y :width :height :rotation :constraints-h
:constraints-v})
(def categories
"Category order, the tone, label key and icon of each one. Colour is
kept for what happened to an entry (added, modified, deleted, in
conflict), so the categories are neutral and told apart by their icon;
decisions alone stay in the conflict colour, as they are conflicts."
[[:decisions "warning" "workspace.branches.review.category.decisions" i/triangle-alert]
[:copy "neutral" "workspace.branches.review.category.copy" i/text]
[:colour "neutral" "workspace.branches.review.category.colour" i/swatches]
[:layout "neutral" "workspace.branches.review.category.layout" i/flex]
[:components "neutral" "workspace.branches.review.category.components" i/component]
[:variants "neutral" "workspace.branches.review.category.variants" i/variant]
[:tokens "neutral" "workspace.branches.review.category.tokens" i/tokens]
[:other "neutral" "workspace.branches.review.category.other" i/layers]])
(def category->tone
(into {} (map (fn [[k tone _ _]] [k tone])) categories))
(def category->label
(into {} (map (fn [[k _ label _]] [k label])) categories))
(def category->icon
(into {} (map (fn [[k _ _ icon]] [k icon])) categories))
(defn category
"The one category a diff entry falls under, most specific first: a
conflict is a decision; a variant is a variant change; a component or
a copy is a component change; a token is a token change; the attrs of a modified
layer decide between copy, colour and layout."
[item]
(let [kind (:kind item)
attrs (set (keys (:changed-attrs item)))]
(cond
(= :conflict (:status item)) :decisions
(:variant? item) :variants
(or (= :component kind) (:component? item) (:component-copy? item))
:components
(contains? #{:token :token-set :token-set-rename :token-set-order :token-theme
:token-active-themes :token-active-sets} kind)
:tokens
(contains? #{:color} kind) :colour
(contains? #{:typography} kind) :copy
(some copy-attrs attrs) :copy
(some colour-attrs attrs) :colour
(some layout-attrs attrs) :layout
:else :other)))
(defn status-tone
"The colour of the bar on a change row: what happened to the entry."
[status]
(case status
:added "success"
:deleted "error"
:conflict "warning"
"info"))
;; --- One-line summaries
(defn- change-phrase
"What changing `attr` did, as a short phrase: the kind of change rather
than its values, which the detail pane shows."
[attr]
(case attr
:content (tr "workspace.branches.review.summary.text")
:name (tr "workspace.branches.review.summary.renamed")
(:x :y) (tr "workspace.branches.review.summary.moved")
(:width :height) (tr "workspace.branches.review.summary.resized")
:rotation (tr "workspace.branches.review.summary.rotated")
(:parent-id :frame-id) (tr "workspace.branches.review.summary.reparented")
:hidden (tr "workspace.branches.review.summary.visibility")
(:layout-gap :layout-padding :layout-padding-type :layout-gap-type)
(tr "workspace.branches.review.summary.spacing")
(:layout :layout-flex-dir :layout-wrap-type :layout-justify-content
:layout-align-items :layout-align-content :layout-justify-items
:layout-grid-rows :layout-grid-columns :layout-grid-cells)
(tr "workspace.branches.review.summary.layout")
(tr "workspace.branches.review.summary.attr" (attr-label attr))))
(defn change-summary
"One line that says what a change did, in words: what was added or
deleted, or the kinds of change a modification made (\"Fill changed\",
\"Moved\"), two at most and a count of the rest. The values belong to
the detail pane."
[item]
(let [status (:status item)
attrs (visible-attrs (:changed-attrs item))
lbl (some-> (type-label-key item) tr)
phrases (into [] (comp (map (comp change-phrase first)) (distinct)) attrs)
shown (take 2 phrases)
more (- (count phrases) (count shown))]
(case status
:added (tr "workspace.branches.compare.subtitle-new" (or lbl ""))
:deleted (tr "workspace.branches.review.summary.deleted" (or lbl ""))
(if (empty? phrases)
(or lbl (tr "workspace.branches.review.summary.changed"))
(cond-> (str/join ", " shown)
(pos? more)
(dm/str " " (tr "workspace.branches.review.summary.more" (i18n/c more))))))))
;; --- Where a change lives: page and board
(defn top-board
"The top-level board that holds shape `id` in `objects` (the shape itself
when it is one), or nil when the shape is not in `objects` or sits on
the page root."
[objects id]
(loop [shape (get objects id)
depth 0]
(when (and shape (< depth 64))
(let [frame-id (:frame-id shape)]
(if (and (= :frame (:type shape))
(or (nil? frame-id) (= frame-id uuid/zero)))
(when-not (= (:id shape) uuid/zero) shape)
(recur (get objects (if (= :frame (:type shape))
frame-id
(or frame-id (:parent-id shape))))
(inc depth)))))))
(defn group-by-place
"Group the clean changes (`[index item]` pairs) by page, and inside a
page by the top-level board that holds them, reading names from the
open file's `pages-index`. Entries that belong to no page (components,
colours, typographies, tokens) go to a last `:library` group, after a
`:pages` group for the pages added, deleted or reordered. Each
group is `{:id :name :page-id :rows [[idx item]] :boards [{:id :name
:rows}]}`, with `:rows` holding the entries outside any board. A board
groups its changes only when it holds two or more of `rows`."
[rows pages-index pages]
(let [by-page (group-by (fn [[_ item]] (:page-id item)) rows)
page-group
(fn [page-id page-rows]
(let [page (get pages-index page-id)
objects (:objects page)
placed (map (fn [[_ item :as row]]
(let [board (when (= :shape (:kind item))
(top-board objects (:id item)))]
[row board]))
page-rows)
;; a board folds its changes only when it holds two or
;; more of the listed ones: a lone change stays loose, as
;; the fold would only repeat it
sizes (frequencies (keep (comp :id second) placed))
placed (map (fn [[row board]]
[row (when (> (get sizes (:id board) 0) 1) board)])
placed)
boards (->> placed
(filter second)
(group-by (comp :id second))
(map (fn [[board-id entries]]
{:id board-id
:name (:name (second (first entries)))
:rows (mapv first entries)}))
(sort-by :name)
vec)]
{:id (str page-id)
:page-id page-id
:name (or (:name page) (tr "workspace.branches.review.unknown-page"))
:rows (into [] (comp (remove second) (map first)) placed)
:boards boards
:count (count page-rows)}))
page-order (into {} (map-indexed (fn [idx id] [id idx])) pages)
loose (get by-page nil)
page-kind #(contains? #{:page :page-order} (:kind (second %)))
page-rows (filterv page-kind loose)
library (filterv (complement page-kind) loose)]
(cond-> (->> (dissoc by-page nil)
(map (fn [[page-id page-rows]] (page-group page-id page-rows)))
(sort-by #(get page-order (:page-id %) 9999))
vec)
(seq page-rows)
(conj {:id "pages"
:name (tr "workspace.branches.review.group.pages")
:rows page-rows
:boards []
:count (count page-rows)})
(seq library)
(conj {:id "library"
:name (tr "workspace.branches.review.group.library")
:rows library
:boards []
:count (count library)}))))
;; --- Conflicts: what clashes and what one side did alone
(defn- attr-sources
"Every changed attr of a conflict with who changed it, using the base
value both sides started from. `:clash` attrs moved on both sides; a
`:main` or `:branch` attr moved on that side alone, so its value is not
in doubt. Without a base (both sides added the entity) every attr
clashes."
[conflict]
(let [base (:base conflict)
has-base? (map? base)]
(->> (:changed-attrs conflict)
(map (fn [[attr {:keys [main branch]}]]
(let [base-v (get base attr)]
{:attr attr
:base base-v
:main main
:branch branch
:source (cond
(not has-base?) :clash
(= base-v main) :branch
(= base-v branch) :main
:else :clash)})))
(sort-by (comp attr-label :attr))
vec)))
(defn conflict-attr-rows
"The attrs of a conflict a person decides on (see `attr-sources`).
Technical attrs are left out: they follow the visible ones they come
with. When only technical attrs clash, they are shown instead, so the
conflict can still be decided."
[conflict]
(let [all (attr-sources conflict)
visible (filterv #(not (contains? technical-attrs (:attr %))) all)]
(if (and (not-any? #(= :clash (:source %)) visible)
(some #(= :clash (:source %)) all))
all
visible)))
(defn clash-attrs
[rows]
(into [] (comp (filter #(= :clash (:source %))) (map :attr)) rows))
(defn- hidden-clashes
"The clashing attrs `conflict-attr-rows` does not show."
[conflict]
(let [shown (into #{} (map :attr) (conflict-attr-rows conflict))]
(into []
(comp (filter #(= :clash (:source %)))
(map :attr)
(remove shown))
(attr-sources conflict))))
(defn prefill-resolution
"The per-attr resolution a conflict starts with: every attr one side
changed alone takes that side. The clashing attrs stay out, so the
conflict is not resolved until the user picks them; a conflict without
a clash comes out resolved."
[conflict]
(into {}
(comp (remove #(= :clash (:source %)))
(map (juxt :attr :source)))
(attr-sources conflict)))
(defn per-attr?
"Whether a conflict can be decided property by property."
[conflict]
(and (seq (:changed-attrs conflict))
(map? (:main conflict))
(map? (:branch conflict))))
(defn choose-attr
"The resolution of `conflict` once `attr` takes `side`, starting from
`res` (nil, a whole-side keyword or a per-attr map)."
[conflict res attr side]
(let [seed (cond
(map? res) res
(= res :branch) (zipmap (keys (:changed-attrs conflict)) (repeat :branch))
(= res :main) (zipmap (keys (:changed-attrs conflict)) (repeat :main))
:else (prefill-resolution conflict))]
(reduce (fn [res hidden] (assoc res hidden side))
(assoc seed attr side)
(hidden-clashes conflict))))
(defn choose-side
"The resolution of `conflict` when every clashing attr takes `side`; the
independent edits keep the side that made them."
[conflict side]
(if (per-attr? conflict)
(let [rows (conflict-attr-rows conflict)]
(reduce (fn [res attr] (assoc res attr side))
(prefill-resolution conflict)
(into (clash-attrs rows) (hidden-clashes conflict))))
side))
(defn attr-choice
"Currently-selected side (`:main`/`:branch`) for `attr` under resolution
`res`, or nil when undecided."
[res attr]
(cond
(= res :branch) :branch
(= res :main) :main
(map? res) (get res attr)
:else nil))
(defn dropped-values
"What a decided conflict leaves behind: for each attr where the other
side changed the value and lost, `{:attr :side :value}` naming the side
whose edit is dropped."
[conflict res]
(let [rows (conflict-attr-rows conflict)]
(into []
(keep (fn [{:keys [attr base main branch]}]
(let [choice (attr-choice res attr)
has-base? (map? (:base conflict))]
(cond
(and (= choice :branch) (or (not has-base?) (not= main base)))
{:attr attr :side :main :value main}
(and (= choice :main) (or (not has-base?) (not= branch base)))
{:attr attr :side :branch :value branch}
:else nil))))
rows)))
(defn paragraph-rows
"The paragraphs of one side of a text conflict, each marked `:kept` when
this side edited it, `:dropped` when only the other side edited it (so
keeping this side drops that edit), or nil when neither did."
[conflict side]
(let [other (if (= side :main) :branch :main)
base (content-paragraphs (get-in conflict [:base :content]))
mine (content-paragraphs (get-in conflict [side :content]))
theirs (content-paragraphs (get-in conflict [other :content]))]
(into []
(map-indexed
(fn [idx text]
(let [b (nth base idx nil)
t (nth theirs idx nil)]
{:index idx
:text text
:status (cond
(not= text b) :kept
(and (some? t) (not= t b)) :dropped
:else nil)})))
mine)))
;; --- What a new or deleted entry is, where it lands, what it affects
(defn page-contents
"The top-level boards of `page` and how many layers it holds, the root
frame aside."
[page]
(let [objects (:objects page)
root (get objects uuid/zero)]
{:boards (into []
(comp (map #(get objects %))
(filter #(= :frame (:type %))))
(:shapes root))
:layers (max 0 (dec (count objects)))}))
(defn shape-path
"The names of the containers that hold shape `id`, from the outermost
board to its direct parent."
[objects id]
(loop [parent-id (:parent-id (get objects id))
path ()
depth 0]
(let [parent (get objects parent-id)]
(if (and parent (not= parent-id uuid/zero) (< depth 64))
(recur (:parent-id parent) (conj path (:name parent)) (inc depth))
(vec path)))))
(defn- all-shapes
[data]
(mapcat (comp vals :objects) (vals (:pages-index data))))
(defn component-copies
"How many copies of component `component-id` the file `data` holds."
[data component-id]
(count (filter #(and (= component-id (:component-id %))
(not (:main-instance %)))
(all-shapes data))))
(defn color-usages
"How many layers of `data` fill or stroke with library colour
`color-id`."
[data color-id]
(count (filter (fn [shape]
(or (some #(= color-id (:fill-color-ref-id %)) (:fills shape))
(some #(= color-id (:stroke-color-ref-id %)) (:strokes shape))))
(all-shapes data))))
(defn typography-usages
"How many text layers of `data` use library typography
`typography-id`."
[data typography-id]
(count (filter (fn [shape]
(and (= :text (:type shape))
(some #(= typography-id (:typography-ref-id %))
(txt/node-seq (:content shape)))))
(all-shapes data))))
(defn token-usages
"How many layers of `data` apply the token named `token-name`."
[data token-name]
(count (filter (fn [shape]
(some #(= token-name %) (vals (:applied-tokens shape))))
(all-shapes data))))

View File

@ -0,0 +1,733 @@
;; 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.branch-review.compare
"The compare dialog: what a branch changes, in words. It opens on the
full list of changes, grouped by page and board, each one with its
before and after."
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.time :as ct]
[app.main.data.modal :as modal]
[app.main.data.workspace.branches :as dwb]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.checkbox :refer [checkbox*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.product.change-row :refer [change-row*]]
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
[app.main.ui.ds.product.filter-chip :refer [filter-chip*]]
[app.main.ui.ds.product.preview-card :refer [preview-card*]]
[app.main.ui.ds.product.tag :refer [tag*]]
[app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.workspace.sidebar.branch-review.common :as brc]
[app.main.ui.workspace.sidebar.branch-review.facts :as brf]
[app.main.ui.workspace.sidebar.branch-review.layout :as brl]
[app.main.ui.workspace.sidebar.branch-review.previews :as brp]
[app.util.i18n :as i18n :refer [tr]]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private branch-diff
(l/derived :workspace-branch-diff st/state))
(defn- selectable-item?
"A row the per-change selection covers: a clean change. A conflict keeps
its resolution UI instead, and an `:unsupported` entry is a refusal the
merge raises whatever the selection says."
[item]
(contains? #{:added :modified :deleted} (:status item)))
(defn- review-key
"The key a row's review mark is stored under."
[item]
(or (:key item) (dm/str (name (:kind item)) ":" (:id item))))
(defn- category-label
[cat]
(tr (brc/category->label cat)))
;; --- List rows
(defn- dom-stop
[event]
(.stopPropagation event))
(mf/defc row-end*
{::mf/private true}
[{:keys [item excluded reviewed]}]
(let [status (:status item)
edits (count (brc/visible-attrs (:changed-attrs item)))]
(cond
;; the warning icon alone: the bar and the group already say it
;; waits for a decision
(= :conflict status)
[:> i/icon* {:icon-id i/triangle-alert
:size "s"
:class (stl/css :row-warning)
:aria-label (tr "workspace.branches.review.row.choose")}]
excluded
[:> tag* {:variant "pill"} (tr "workspace.branches.review.row.left-out")]
;; reviewing is the user's progress, not what happened to the
;; entry: a quiet eye, seen by a person, never a coloured status
reviewed
[:> tooltip* {:content (tr "workspace.branches.review.row.reviewed")
:placement "top"}
[:> i/icon* {:icon-id i/shown
:size "s"
:class (stl/css :row-reviewed)}]]
(= :added status)
[:> tag* {:tone "success" :variant "pill"} (tr "workspace.branches.status.added")]
(= :deleted status)
[:> tag* {:tone "error" :variant "pill"} (tr "workspace.branches.status.deleted")]
(> edits 1)
[:> tag* {:tone "info" :variant "pill"}
(tr "workspace.branches.review.row.edits" (i18n/c edits))]
:else nil)))
(mf/defc item-row*
{::mf/private true}
[{:keys [index item selected excluded reviewed on-select on-toggle]}]
(let [selectable? (selectable-item? item)
change-key (:key item)
on-click (mf/use-fn (mf/deps index on-select) #(on-select {:kind :item :index index}))
start (when selectable?
(mf/html
[:span {:class (stl/css :row-check)
;; the click must not also select the row
:on-click dom-stop}
[:> checkbox* {:id (dm/str "change-" change-key)
:checked (not excluded)
:on-change #(on-toggle #{change-key})}]]))]
[:> change-row* {:title (or (:label item) "")
:subtitle (brc/change-summary item)
:icon (brc/entry-icon item)
:tone (if (and excluded selectable?) "neutral" (brc/status-tone (:status item)))
:is-selected (= selected {:kind :item :index index})
:is-muted (and excluded selectable?)
:start start
:end (mf/html [:> row-end* {:item item :excluded excluded :reviewed reviewed}])
:on-click on-click}]))
(mf/defc board-row*
"A top-level board that holds changes; its changes fold under it."
{::mf/private true}
[{:keys [group board selected collapsed on-select on-fold children]}]
(let [sel {:kind :board :page-id (:page-id group) :board-id (:id board)}
open? (not (contains? collapsed (:id board)))
on-click (mf/use-fn (mf/deps sel on-select) #(on-select sel))
on-chevron (mf/use-fn (mf/deps board on-fold)
(fn [event]
(.stopPropagation event)
(on-fold (:id board))))]
[:*
[:> change-row* {:title (or (:name board) "")
:subtitle (tr "workspace.branches.review.board.inside"
(i18n/c (count (:rows board))))
;; a fold is not a change: no status colour
:tone "neutral"
:is-selected (= selected sel)
:start (mf/html
[:button {:class (stl/css :row-fold)
:aria-expanded open?
:on-click on-chevron}
[:> i/icon* {:icon-id (if open? i/arrow-down i/arrow-right)
:size "s"}]])
:on-click on-click}]
(when open? children)]))
(mf/defc change-list*
{::mf/private true}
[{:keys [groups conflicts selected excluded reviewed collapsed
on-select on-toggle on-fold]}]
[:*
(when (seq conflicts)
(let [open? (not (contains? collapsed "decisions"))]
[:div {:class (stl/css :group)}
[:> brl/group-head* {:label (tr "workspace.branches.review.group.decisions"
(str (count conflicts)))
:is-open open?
:on-toggle #(on-fold "decisions")}]
(when open?
(for [[idx item] conflicts]
[:> item-row* {:key idx :index idx :item item
:selected selected
:reviewed (contains? reviewed (review-key item))
:on-select on-select :on-toggle on-toggle}]))]))
(for [{:keys [id name rows boards count] :as group} groups]
(let [open? (not (contains? collapsed id))
row-keys (into #{} (comp (filter (fn [[_ it]] (selectable-item? it)))
(map (fn [[_ it]] (:key it))))
(concat rows (mapcat :rows boards)))
included? (not-any? excluded row-keys)]
[:div {:class (stl/css :group) :key id}
[:> brl/group-head*
{:label (dm/str name " (" count ")")
:is-open open?
:on-toggle #(on-fold id)
:start (when (seq row-keys)
(mf/html
[:span {:class (stl/css :row-check)}
[:> checkbox* {:id (dm/str "group-" id)
:checked included?
:on-change #(on-toggle row-keys (not included?))}]]))}]
(when open?
[:*
(for [[idx item] rows]
[:> item-row* {:key idx :index idx :item item
:selected selected
:excluded (contains? excluded (:key item))
:reviewed (contains? reviewed (review-key item))
:on-select on-select :on-toggle on-toggle}])
(for [board boards]
[:> board-row* {:key (str (:id board))
:group group :board board
:selected selected :collapsed collapsed
:on-select on-select :on-fold on-fold}
(for [[idx item] (:rows board)]
[:> item-row* {:key idx :index idx :item item
:selected selected
:excluded (contains? excluded (:key item))
:reviewed (contains? reviewed (review-key item))
:on-select on-select :on-toggle on-toggle}])])])]))])
;; --- Previews
(def ^:private view-modes
[[:side i/side-by-side "workspace.branches.review.view.side"]
[:overlay i/boolean-intersection "workspace.branches.review.view.overlay"]])
(defn- library-entity
"A library entry (colour, typography) as the open file holds it."
[data item]
(case (:kind item)
:color (get-in data [:colors (:id item)])
:typography (get-in data [:typographies (:id item)])
nil))
(mf/defc side-cards*
"MAIN and BRANCH, side by side, each with what it holds for the change."
{::mf/private true}
[{:keys [kind main-value branch-value main-objects branch-objects
main-detail branch-detail main-missing branch-missing large]}]
[:div {:class (stl/css :cards)}
[:> preview-card* {:label (tr "workspace.branches.review.side.main")
:side "main"
:detail main-detail
:is-large (boolean large)}
[:> brp/entity-preview* {:kind kind
:value main-value
:objects main-objects
:missing main-missing}]]
[:> preview-card* {:label (tr "workspace.branches.review.side.branch")
:side "branch"
:detail branch-detail
:is-large (boolean large)}
[:> brp/entity-preview* {:kind kind
:value branch-value
:objects branch-objects
:missing branch-missing}]]])
(mf/defc view-modes*
"Side by side or overlay for the previews of a change, as icon toggles,
then the toggle that gives the previews the whole dialog. `show-modes`
leaves the view toggles out for a change with nothing to overlay."
{::mf/private true}
[{:keys [mode on-change show-modes expanded on-expand]}]
[:div {:class (stl/css :view-modes)}
(when show-modes
(for [[m icon label] view-modes]
[:> icon-button* {:key (name m)
:variant "ghost"
:class (stl/css :view-toggle)
:icon icon
:aria-label (tr label)
:aria-pressed (= m mode)
:on-click #(on-change m)}]))
[:> icon-button* {:variant "ghost"
:icon (if expanded i/collapse i/expand)
:aria-label (tr (if expanded
"workspace.branches.review.view.collapse"
"workspace.branches.review.view.expand"))
:on-click on-expand}]])
(mf/defc item-visual*
"The visual comparison of one change, in the view `mode`."
{::mf/private true}
[{:keys [item items open-side incoming data diff-meta mode large]}]
(let [kind (:kind item)
page-id (:page-id item)
objects (when (= :shape kind)
(get-in data [:pages-index page-id :objects]))
page-items (mf/with-memo [items page-id]
(filterv #(= page-id (:page-id %)) items))
main-objs (mf/with-memo [objects page-items open-side]
(when objects (brp/side-objects objects page-items :main open-side)))
branch-objs (mf/with-memo [objects page-items open-side]
(when objects (brp/side-objects objects page-items :branch open-side)))
main-v (if (= :shape kind)
(or (get main-objs (:id item)) (get item :main))
(brp/side-entity item :main open-side (library-entity data item)))
branch-v (if (= :shape kind)
(or (get branch-objs (:id item)) (get item :branch))
(brp/side-entity item :branch open-side (library-entity data item)))
main-detail (some-> (:main-at diff-meta) ct/inst ct/timeago)
branch-detail (some-> (:branch-at diff-meta) ct/inst ct/timeago)]
(when (or main-v branch-v)
[:div {:class (stl/css :visual)}
(if (= mode :overlay)
[:> preview-card* {:label (tr "workspace.branches.review.view.overlay")
:side "base"
:is-large (boolean large)
:detail (tr "workspace.branches.review.overlay-detail")}
[:div {:class (stl/css :overlay-stage)}
(when main-v
[:div {:class (stl/css :overlay-layer)}
[:> brp/entity-preview* {:kind kind :value main-v :objects main-objs}]])
(when branch-v
[:div {:class (stl/css :overlay-layer :is-top)}
[:> brp/entity-preview* {:kind kind :value branch-v :objects branch-objs}]])]]
[:> side-cards* {:kind kind
:large large
:main-value main-v
:branch-value branch-v
:main-objects main-objs
:branch-objects branch-objs
:main-detail main-detail
:branch-detail branch-detail
:main-missing {:reason (brp/missing-reason item :main incoming)
:side :main
:open-side open-side}
:branch-missing {:reason (brp/missing-reason item :branch incoming)
:side :branch
:open-side open-side}}])])))
;; --- Detail panes
(mf/defc item-detail*
{::mf/private true}
[{:keys [item items indexed excluded excluded-keys reviewed selected open-side incoming
data diff-meta on-resolve on-select on-toggle expanded on-expand]}]
(let [type-lbl (brc/type-label-key item)
cat (brc/category item)
attrs (:changed-attrs item)
visible (brc/visible-attrs attrs)
conflict? (= :conflict (:status item))
mode* (mf/use-state :side)
mode (deref mode*)]
[:*
[:div {:class (stl/css :detail-head)}
[:div {:class (stl/css :detail-head-main)}
[:> heading* {:level 3 :typography t/body-large :class (stl/css :detail-title)}
(:label item)]
(when type-lbl
[:> tag* {} (tr type-lbl)])
;; a decision already shows as one (the warning row, the notice);
;; its category tag would only repeat it
(when-not conflict?
[:> tag* {:tone (brc/category->tone cat)
:icon (brc/category->icon cat)}
(category-label cat)])
(when (and excluded (selectable-item? item))
[:> tag* {} (tr "workspace.branches.review.row.left-out")])]
[:> view-modes* {:mode mode
:on-change #(reset! mode* %)
:show-modes (= :shape (:kind item))
:expanded expanded
:on-expand on-expand}]]
[:> item-visual* {:item item
:items items
:open-side open-side
:incoming incoming
:data data
:diff-meta diff-meta
:mode mode
:large expanded}]
(when conflict?
[:> brl/notice* {:level :warning}
(tr "workspace.branches.review.decision.body")])
;; the summary heads the property table; a decision puts its way in
;; on the same line
[:div {:class (stl/css :detail-summary-row)}
[:> text* {:as "p" :typography t/body-small :class (stl/css :detail-summary)}
(brc/change-summary item)]
(when (and conflict? on-resolve)
[:> button* {:variant "secondary" :on-click on-resolve}
(tr "workspace.branches.review.row.choose")])]
(if (seq visible)
[:> brl/value-steps* {:attrs visible}]
(when (= :modified (:status item))
[:> text* {:as "p" :typography t/body-small :class (stl/css :detail-summary)}
(tr "workspace.branches.compare.no-props")]))
;; an entry without a before and after says what it is, where it
;; lands and what it affects instead
(when-not conflict?
(case (:kind item)
:page
(when (contains? #{:added :deleted} (:status item))
(let [rows (filterv (fn [[_ it]] (= (:id item) (:page-id it))) indexed)]
[:> brf/page-facts* {:item item
:data data
:open-side open-side
:incoming incoming
:change-count (count rows)}
(for [[idx it] rows]
[:> item-row* {:key idx :index idx :item it
:selected selected
:excluded (contains? excluded-keys (:key it))
:reviewed (contains? reviewed (review-key it))
:on-select on-select :on-toggle on-toggle}])]))
:shape
(when (contains? #{:added :deleted} (:status item))
[:> brf/shape-facts* {:item item :data data :incoming incoming}])
(:component :color :typography :token)
[:> brf/usage-facts* {:item item :data data}]
nil))]))
(mf/defc board-detail*
{::mf/private true}
[{:keys [page-id board-id groups items open-side data diff-meta selected excluded reviewed
on-select on-toggle]}]
(let [group (some #(when (= page-id (:page-id %)) %) groups)
board (some #(when (= board-id (:id %)) %) (:boards group))
rows (:rows board)
objects (get-in data [:pages-index page-id :objects])
page-items (mf/with-memo [items page-id]
(filterv #(= page-id (:page-id %)) items))
main-objs (mf/with-memo [objects page-items open-side]
(when objects (brp/side-objects objects page-items :main open-side)))
branch-objs (mf/with-memo [objects page-items open-side]
(when objects (brp/side-objects objects page-items :branch open-side)))]
(when board
[:*
[:div {:class (stl/css :detail-head)}
[:> heading* {:level 3 :typography t/body-large :class (stl/css :detail-title)}
(:name board)]
[:> tag* {} (tr "workspace.branches.compare.shape.board")]]
[:> text* {:as "p" :typography t/body-small :class (stl/css :detail-summary)}
(dm/str (:name group) " › " (:name board) " - "
(tr "workspace.branches.review.board.inside" (i18n/c (count rows))))]
[:div {:class (stl/css :cards)}
[:> preview-card* {:label (tr "workspace.branches.review.side.main")
:side "main"
:detail (some-> (:main-at diff-meta) ct/inst ct/timeago)}
[:> brp/entity-preview* {:kind :shape
:value (get main-objs board-id)
:objects main-objs
:missing {:side :main :open-side open-side}}]]
[:> preview-card* {:label (tr "workspace.branches.review.side.branch")
:side "branch"
:detail (some-> (:branch-at diff-meta) ct/inst ct/timeago)}
[:> brp/entity-preview* {:kind :shape
:value (get branch-objs board-id)
:objects branch-objs
:missing {:side :branch :open-side open-side}}]]]
[:div {:class (stl/css :inner-list)}
(for [[idx item] rows]
[:> item-row* {:key idx :index idx :item item
:selected selected
:excluded (contains? excluded (:key item))
:reviewed (contains? reviewed (review-key item))
:on-select on-select :on-toggle on-toggle}])]])))
;; --- The dialog
(defn- display-order
"Every row index in the order the list shows them: decisions first,
then each page's loose rows and its boards' rows."
[conflicts groups]
(-> []
(into (map first) conflicts)
(into (mapcat (fn [{:keys [rows boards]}]
(concat (map first rows)
(mapcat (comp (partial map first) :rows) boards))))
groups)))
(mf/defc branch-compare-dialog*
{::mf/register modal/components
::mf/register-as :branch-compare}
[{:keys [branch is-review]}]
(let [{:keys [status diff direction excluded reviewed]} (mf/deref branch-diff)
file-id (:id (mf/deref refs/file))
data (mf/deref refs/workspace-data)
direction (or direction :branch->main)
incoming? (= direction :main->branch)
excluded (or excluded #{})
reviewed (or reviewed #{})
open-side (brp/open-side branch file-id)
diff-meta (:meta diff)
items (mf/with-memo [diff]
(into (vec (:changes diff)) (:conflicts diff)))
indexed (mf/with-memo [items]
(into [] (map-indexed vector) items))
counts (mf/with-memo [items]
(group-by brc/category items))
filter* (mf/use-state :all)
active-filter (deref filter*)
selected* (mf/use-state nil)
selected (deref selected*)
;; expanded: the list steps aside and the previews take the
;; whole dialog
expanded* (mf/use-state false)
expanded (deref expanded*)
on-expand (mf/use-fn #(swap! expanded* not))
collapsed* (mf/use-state #{})
collapsed (deref collapsed*)
visible (mf/with-memo [indexed active-filter excluded]
(filterv (fn [[_ item]]
(case active-filter
:all true
:left-out (contains? excluded (:key item))
(= active-filter (brc/category item))))
indexed))
conflicts (mf/with-memo [visible]
(filterv (fn [[_ it]] (= :conflict (:status it))) visible))
decisions (count (filter #(= :conflict (:status %)) items))
groups (mf/with-memo [visible data]
(brc/group-by-place (filterv (fn [[_ it]] (not= :conflict (:status it))) visible)
(:pages-index data)
(:pages data)))
order (mf/with-memo [conflicts groups]
(display-order conflicts groups))
all-keys (into #{} (comp (filter selectable-item?) (map :key)) items)
kept-count (count (remove excluded all-keys))
left-out (count (filter excluded all-keys))
reviewed-n (count (filter #(contains? reviewed (review-key %)) items))
sel-index (when (= :item (:kind selected)) (:index selected))
sel-item (when (and (some? sel-index) (< sel-index (count items)))
(nth items sel-index))
pos (when sel-index (.indexOf order sel-index))
on-close (mf/use-fn #(st/emit! (dwb/clear-diff-excluded) (modal/hide)))
on-select (mf/use-fn #(reset! selected* %))
on-toggle (mf/use-fn (fn [change-keys include?]
(st/emit! (dwb/toggle-diff-excluded change-keys include?))))
on-fold (mf/use-fn (fn [id] (swap! collapsed* #(if (contains? % id) (disj % id) (conj % id)))))
on-filter (mf/use-fn (fn [f]
(reset! filter* f)
(reset! selected* nil)))
on-swap (mf/use-fn (mf/deps branch direction)
#(st/emit! (dwb/fetch-branch-diff
(:id branch)
(if incoming? :branch->main :main->branch))))
on-merge (mf/use-fn (mf/deps branch excluded)
#(brc/confirm-merge! branch excluded))
on-update (mf/use-fn (mf/deps branch excluded)
#(brc/confirm-update! branch excluded))
on-resolve (mf/use-fn (mf/deps branch direction excluded)
#(modal/show! :branch-conflicts
{:branch branch
:mode (if incoming? :update :merge)
:excluded excluded}))
on-previous (mf/use-fn (mf/deps order pos)
(fn []
(when (and pos (pos? pos))
(reset! selected* {:kind :item :index (nth order (dec pos))}))))
on-mark-next
(mf/use-fn
(mf/deps order pos sel-item items reviewed)
(fn []
(when sel-item
(st/emit! (dwb/toggle-diff-reviewed (review-key sel-item) true)))
(let [start (if pos (inc pos) 0)
ahead (concat (drop start order) (take start order))
next-ix (some (fn [idx]
(when (and (not= idx (:index selected))
(not (contains? reviewed (review-key (nth items idx)))))
idx))
ahead)]
(when next-ix
(reset! selected* {:kind :item :index next-ix})))))
on-export (mf/use-fn
(mf/deps diff branch)
(fn []
(let [payload (clj->js {:stats (:stats diff)
:changes (:changes diff)
:conflicts (:conflicts diff)})
text (js/JSON.stringify payload nil 2)
blob (js/Blob. #js [text] #js {:type "application/json"})
url (js/URL.createObjectURL blob)
a (.createElement js/document "a")]
(set! (.-href a) url)
(set! (.-download a) (dm/str (:name branch) "-diff.json"))
(.click a)
(js/URL.revokeObjectURL url))))
primary
(mf/html
(cond
(pos? decisions)
[:> button* {:variant "primary" :on-click on-resolve}
(tr "workspace.branches.review.action.resolve" (i18n/c decisions))]
incoming?
[:> button* {:variant "primary"
:disabled (zero? kept-count)
:on-click on-update}
(tr "workspace.branches.review.action.update" (i18n/c kept-count))]
:else
[:> button* {:variant "primary"
:disabled (zero? kept-count)
:on-click on-merge}
(tr "workspace.branches.review.action.merge" (i18n/c kept-count))]))]
(mf/with-effect [(:id branch)]
(st/emit! (dwb/fetch-branch-diff (:id branch))))
;; the list opens on its first row
(mf/with-effect [status order]
(when (and (= status :loaded) (nil? selected) (seq order))
(reset! selected* {:kind :item :index (first order)})))
[:> brl/review-modal*
{:icon i/git-compare-arrows
:title (tr "workspace.branches.review.title")
:route (mf/html [:> brl/route* {:branch-name (:name branch)
:is-review is-review
:incoming incoming?
:on-swap on-swap}])
:filters (when (and (= status :loaded) (seq items) (not expanded))
(mf/html
[:*
[:> filter-chip* {:label (dm/str (tr "labels.all") " (" (count items) ")")
:is-selected (= active-filter :all)
:on-click #(on-filter :all)}]
(for [[cat tone label icon] brc/categories
:let [n (count (get counts cat))]
:when (pos? n)]
[:> filter-chip* {:key (name cat)
:label (dm/str (tr label) " (" n ")")
:icon icon
:tone tone
:is-selected (= active-filter cat)
:on-click #(on-filter cat)}])
(when (pos? left-out)
[:> filter-chip* {:label (dm/str (tr "workspace.branches.review.row.left-out")
" (" left-out ")")
:is-selected (= active-filter :left-out)
:on-click #(on-filter :left-out)}])]))
:list (when (and (= status :loaded) (seq items) (not expanded))
(mf/html
[:> change-list* {:groups groups
:conflicts conflicts
:selected selected
:excluded excluded
:reviewed reviewed
:collapsed collapsed
:on-select on-select
:on-toggle on-toggle
:on-fold on-fold}]))
:detail
(mf/html
(cond
(= status :error)
[:div {:class (stl/css :empty)}
[:> empty-state* {:icon i/triangle-alert :text (tr "workspace.branches.compare.error")}]]
(not= status :loaded)
[:div {:class (stl/css :empty)}
[:> empty-state* {:icon i/git-compare-arrows :text (tr "workspace.branches.compare.loading")}]]
(empty? items)
[:div {:class (stl/css :empty)}
[:> empty-state* {:icon i/git-branch :text (tr "workspace.branches.compare.empty")}]]
(= :board (:kind selected))
[:> board-detail* {:page-id (:page-id selected)
:board-id (:board-id selected)
:groups groups
:items items
:open-side open-side
:data data
:diff-meta diff-meta
:selected selected
:excluded excluded
:reviewed reviewed
:on-select on-select
:on-toggle on-toggle}]
sel-item
[:> item-detail* {:key (:index selected)
:item sel-item
:items items
:indexed indexed
:excluded (contains? excluded (:key sel-item))
:excluded-keys excluded
:reviewed reviewed
:selected selected
:on-select on-select
:on-toggle on-toggle
:expanded expanded
:on-expand on-expand
:open-side open-side
:incoming incoming?
:data data
:diff-meta diff-meta
:on-resolve on-resolve}]
:else
[:div {:class (stl/css :empty)}
[:> empty-state* {:icon i/git-compare-arrows :text (tr "workspace.branches.compare.select-hint")}]]))
:footer-start
(when (= status :loaded)
(mf/html
[:div {:class (stl/css :footer-start)}
[:> icon-button* {:variant "ghost"
:icon i/download
:aria-label (tr "workspace.branches.compare.export")
:on-click on-export}]
[:> text* {:as "span" :typography t/body-small :class (stl/css :footer-status)}
(str/join " - "
(remove nil?
[(tr "workspace.branches.review.footer.reviewed"
(str reviewed-n) (str (count items)))
(when (pos? decisions)
(tr "workspace.branches.review.footer.blocked" (i18n/c decisions)))
(when (pos? left-out)
(tr "workspace.branches.review.footer.left-out" (i18n/c left-out)))]))]]))
:footer-end
(when (and (= status :loaded) (seq items))
(mf/html
[:*
[:> button* {:variant "secondary"
:disabled (not (and pos (pos? pos)))
:on-click on-previous}
(tr "workspace.branches.review.action.previous")]
[:> button* {:variant "secondary"
:disabled (nil? sel-item)
:on-click on-mark-next}
(tr "workspace.branches.review.action.mark-next")]
primary]))
:on-close on-close}]))

View File

@ -0,0 +1,161 @@
// 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/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/mixins.scss" as *;
@use "ds/typography.scss" as *;
// --- List
.group {
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
}
// the DS checkbox keeps room for a label even without one; the slot
// holds the box alone, so the next item sits one gap away from it
.row-check {
display: flex;
flex-shrink: 0;
inline-size: $sz-16;
overflow: visible;
}
.row-fold {
display: flex;
align-items: center;
padding: 0;
border: none;
background: none;
color: var(--color-foreground-secondary);
cursor: pointer;
}
// --- Detail
//
// Every block of the detail pane sets `flex-shrink: 0`: the pane is a
// column that scrolls, and a block allowed to shrink gets clipped
// instead (the property table did).
.empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
}
// title and tags on the left, the view modes on the right of the same
// line
.detail-head {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--sp-s);
}
.detail-head-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-s);
min-inline-size: 0;
}
.detail-title {
color: var(--color-foreground-primary);
overflow-wrap: anywhere;
}
.detail-summary {
flex-shrink: 0;
margin: 0;
color: var(--color-foreground-secondary);
}
.detail-summary-row {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--sp-s);
min-block-size: $sz-32;
}
.visual {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--sp-m);
}
.view-modes {
display: flex;
align-items: center;
gap: var(--sp-xxs);
}
// the ghost icon button of the DS, as the dialog's close button; the
// view picked reads in the primary foreground, not in the accent
.view-toggle[aria-pressed="true"] {
--button-active-fg-color: var(--color-foreground-primary);
}
.cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-m);
}
.overlay-stage {
display: grid;
inline-size: 100%;
block-size: 100%;
}
.overlay-layer {
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
min-block-size: 0;
&.is-top {
opacity: 0.5;
}
}
.inner-list {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
padding: var(--sp-xs);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
}
.footer-status {
color: var(--color-foreground-secondary);
}
.footer-start {
display: flex;
align-items: center;
gap: var(--sp-s);
}
.row-warning {
color: var(--color-accent-warning);
}
.row-reviewed {
color: var(--color-foreground-secondary);
}

View File

@ -0,0 +1,571 @@
;; 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.branch-review.conflicts
"The decisions dialog: one entity both sides changed at a time. Only the
properties that really clash ask for a choice; the ones a single side
changed are filled in from that side, and the dialog says what each
choice drops before the merge applies it."
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.files.branch-merge :as bm]
[app.common.time :as ct]
[app.main.data.modal :as modal]
[app.main.data.workspace.branches :as dwb]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.controls.checkbox :refer [checkbox*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.product.change-row :refer [change-row*]]
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
[app.main.ui.ds.product.filter-chip :refer [filter-chip*]]
[app.main.ui.ds.product.preview-card :refer [preview-card*]]
[app.main.ui.ds.product.tag :refer [tag*]]
[app.main.ui.workspace.sidebar.branch-review.common :as brc]
[app.main.ui.workspace.sidebar.branch-review.layout :as brl]
[app.main.ui.workspace.sidebar.branch-review.previews :as brp]
[app.util.clipboard :as clipboard]
[app.util.i18n :as i18n :refer [tr]]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private branch-diff
(l/derived :workspace-branch-diff st/state))
(def ^:private reason->label
{:modify-modify "workspace.branches.conflicts.reason.modify-modify"
:add-add "workspace.branches.conflicts.reason.add-add"
:delete-modify "workspace.branches.conflicts.reason.delete-modify"
:modify-delete "workspace.branches.conflicts.reason.modify-delete"})
(defn- text-clash?
"A conflict whose text content both sides rewrote."
[conflict]
(and (brc/per-attr? conflict)
(some #(and (= :content (:attr %)) (= :clash (:source %)))
(brc/conflict-attr-rows conflict))))
(defn- loses-work?
"Whether every choice on a text conflict drops a paragraph edit: both
sides edited different paragraphs."
[conflict]
(and (text-clash? conflict)
(some #(= :dropped (:status %)) (brc/paragraph-rows conflict :main))
(some #(= :dropped (:status %)) (brc/paragraph-rows conflict :branch))))
(defn- row-subtitle
[conflict res]
(let [rows (brc/conflict-attr-rows conflict)
clash (brc/clash-attrs rows)
drops (brc/dropped-values conflict res)]
(cond
(bm/conflict-resolved? conflict res)
(cond
(= res :main) (tr "workspace.branches.resolve.row.kept-main")
(= res :branch) (tr "workspace.branches.resolve.row.kept-branch")
(seq drops) (tr "workspace.branches.resolve.row.drops"
(str/lower (str/join ", " (map (comp brc/attr-label :attr) drops))))
:else (tr "workspace.branches.resolve.row.kept-both"))
(seq clash)
(tr "workspace.branches.resolve.row.both-changed"
(str/join ", " (map brc/attr-label clash)))
:else
(some-> (get reason->label (:reason conflict)) tr))))
(mf/defc conflict-row*
{::mf/private true}
[{:keys [conflict index selected res on-select]}]
(let [resolved? (bm/conflict-resolved? conflict res)
on-click (mf/use-fn (mf/deps index on-select) #(on-select index))]
[:> change-row* {:title (or (:label conflict) "")
:subtitle (row-subtitle conflict res)
:icon (brc/entry-icon conflict)
:tone (if resolved? "success" "warning")
:is-selected (= index selected)
:end (mf/html
(cond
resolved?
[:> tag* {:tone "success" :variant "pill"}
(tr "workspace.branches.resolve.decided")]
(loses-work? conflict)
[:> tag* {:tone "error" :variant "pill"}
(tr "workspace.branches.resolve.row.loses-work")]
:else
[:> i/icon* {:icon-id i/triangle-alert
:size "s"
:class (stl/css :row-warning)
:aria-label (tr "workspace.branches.review.row.choose")}]))
:on-click on-click}]))
;; --- Property table
(mf/defc attr-table*
"Property | Main | Branch | Result, with the source of each result."
{::mf/private true}
[{:keys [rows res on-pick]}]
[:div {:class (stl/css :table)}
[:div {:class (stl/css :table-row :is-head)}
(for [k ["workspace.branches.resolve.th.property"
"workspace.branches.resolve.th.main"
"workspace.branches.resolve.th.branch"
"workspace.branches.resolve.th.result"]]
[:> text* {:key k :as "span" :typography t/headline-small :class (stl/css :th)} (tr k)])
[:span]]
(for [{:keys [attr main branch source]} rows
:let [choice (brc/attr-choice res attr)
clash? (= source :clash)
result (case (or choice (when-not clash? source))
:main main
:branch branch
nil)
decided? (some? (or choice (when-not clash? source)))]]
[:div {:class (stl/css-case :table-row true :is-clash (and clash? (nil? choice)))
:key (str attr)}
[:> text* {:as "span" :typography t/body-small :class (stl/css :td-name)}
(brc/attr-label attr)]
(if (= source :branch)
[:> text* {:as "span" :typography t/body-small :class (stl/css :td-unchanged)}
(tr "workspace.branches.resolve.unchanged")]
[:button {:class (stl/css-case :td-pick true :is-chosen (and clash? (= choice :main)))
:disabled (not clash?)
:on-click #(on-pick attr :main)}
[:> brl/value-chip* {:value main :is-old (not= choice :main)}]])
(if (= source :main)
[:> text* {:as "span" :typography t/body-small :class (stl/css :td-unchanged)}
(tr "workspace.branches.resolve.unchanged")]
[:button {:class (stl/css-case :td-pick true :is-chosen (and clash? (= choice :branch)))
:disabled (not clash?)
:on-click #(on-pick attr :branch)}
[:> brl/value-chip* {:value branch :is-old (not= choice :branch)}]])
(if decided?
[:span {:class (stl/css :td-result)}
[:> brl/value-chip* {:value result}]]
[:> text* {:as "span" :typography t/body-small :class (stl/css :td-choose)}
(tr "workspace.branches.resolve.choose-side")])
[:span {:class (stl/css :td-tag)}
(cond
;; a decided clash is done (green); an independent edit takes the
;; colour of the side it comes from, as the preview cards do
(and clash? choice)
[:> tag* {:tone "success"} (tr "workspace.branches.resolve.tag.your-choice")]
clash?
[:> tag* {:tone "warning"} (tr "workspace.branches.resolve.tag.clash")]
(= source :main)
[:> tag* {:tone "purple"} (tr "workspace.branches.resolve.tag.from-main")]
:else
[:> tag* {:tone "accent"} (tr "workspace.branches.resolve.tag.from-branch")])]])])
;; --- Whole-version and text choices
(mf/defc option-card*
"A choice between the two versions, as a radio card."
{::mf/private true}
[{:keys [label side detail is-chosen warning on-choose children]}]
[:button {:class (stl/css-case :option true :is-chosen is-chosen)
:role "radio"
:aria-checked (boolean is-chosen)
:on-click on-choose}
[:div {:class (stl/css :option-head)}
[:span {:class (stl/css-case :option-radio true :is-chosen is-chosen) :aria-hidden true}]
[:> text* {:as "span" :typography t/body-small :class (stl/css :option-label)} label]]
[:div {:class (stl/css :option-side)}
[:> text* {:as "span" :typography t/headline-small
:class (stl/css-case :option-side-label true :is-main (= side :main))}
(tr (if (= side :main)
"workspace.branches.review.side.main"
"workspace.branches.review.side.branch"))]
(when detail
[:> text* {:as "span" :typography t/body-small :class (stl/css :option-detail)} detail])]
children
(when warning
[:span {:class (stl/css :option-warning)}
[:> i/icon* {:icon-id i/triangle-alert :size "s"}]
warning])])
(mf/defc paragraph-list*
{::mf/private true}
[{:keys [rows]}]
[:div {:class (stl/css :paragraphs)}
(for [{:keys [index text status]} rows]
[:> text* {:key index
:as "span"
:typography t/body-small
:class (stl/css-case :paragraph true
:is-kept (= status :kept)
:is-dropped (= status :dropped))}
(dm/str "¶" (inc index) " " text)])])
(defn- dropped-paragraphs
"The paragraph edits keeping `side` drops."
[conflict side]
(let [other (if (= side :main) :branch :main)]
(filterv #(= :kept (:status %)) (brc/paragraph-rows conflict other))))
(mf/defc text-choice*
{::mf/private true}
[{:keys [conflict res main-detail branch-detail on-pick]}]
(let [choice (brc/attr-choice res :content)
on-copy (mf/use-fn
(mf/deps conflict choice)
(fn []
(when choice
(let [lost (dropped-paragraphs conflict choice)]
(-> (clipboard/to-clipboard (str/join "\n" (map :text lost)))
(.catch (constantly nil)))))))]
[:*
[:div {:class (stl/css :options)}
(for [side [:main :branch]
:let [lost (dropped-paragraphs conflict side)]]
[:> option-card* {:key (name side)
:label (tr (if (= side :main)
"workspace.branches.resolve.keep-main-text"
"workspace.branches.resolve.keep-branch-text"))
:side side
:detail (if (= side :main) main-detail branch-detail)
:is-chosen (= choice side)
:warning (when (seq lost)
(tr "workspace.branches.resolve.drops-paragraphs"
(str/join ", " (map #(dm/str "¶" (inc (:index %))) lost))))
:on-choose #(on-pick :content side)}
[:> paragraph-list* {:rows (brc/paragraph-rows conflict side)}]])]
(when (and choice (seq (dropped-paragraphs conflict choice)))
[:div {:class (stl/css :inline-actions)}
[:> text* {:as "span" :typography t/body-small :class (stl/css :muted)}
(tr "workspace.branches.resolve.keep-safe")]
[:> button* {:variant "secondary" :on-click on-copy}
(tr "workspace.branches.resolve.copy-dropped")]])]))
(mf/defc whole-choice*
"The choice for a conflict that cannot be split by property: one side
deleted the entity, or both added it differently."
{::mf/private true}
[{:keys [conflict res main-detail branch-detail on-choose]}]
[:div {:class (stl/css :options)}
(for [side [:main :branch]
:let [value (get conflict side)]]
[:> option-card* {:key (name side)
:label (tr (if (= side :main)
"workspace.branches.resolve.keep-main-version"
"workspace.branches.resolve.keep-branch-version"))
:side side
:detail (if (= side :main) main-detail branch-detail)
:is-chosen (= res side)
:warning (when (nil? value)
(tr "workspace.branches.resolve.deletes-it"))
:on-choose #(on-choose side)}
[:div {:class (stl/css :option-stage)}
[:> brp/entity-preview* {:kind (:kind conflict)
:value value
:missing {:reason :deleted :side side}}]]])])
;; --- Detail
(mf/defc conflict-detail*
{::mf/private true}
[{:keys [conflict res diff-meta on-pick on-choose on-clear]}]
(let [rows (brc/conflict-attr-rows conflict)
per-attr? (brc/per-attr? conflict)
text? (text-clash? conflict)
table-rows (if text? (filterv #(not= :content (:attr %)) rows) rows)
clash-n (count (brc/clash-attrs rows))
indep-n (- (count rows) clash-n)
resolved? (bm/conflict-resolved? conflict res)
drops (when resolved? (brc/dropped-values conflict res))
main-detail (some-> (:main-at diff-meta) ct/inst ct/timeago)
branch-detail (some-> (:branch-at diff-meta) ct/inst ct/timeago)
prefill (brc/prefill-resolution conflict)
result (when per-attr?
(reduce (fn [entity attr]
(let [side (or (brc/attr-choice res attr)
(get prefill attr)
:main)
v (get-in conflict [side attr])]
(if (nil? v) (dissoc entity attr) (assoc entity attr v))))
(:main conflict)
(keys (:changed-attrs conflict))))]
[:*
[:div {:class (stl/css :detail-head)}
[:> heading* {:level 3 :typography t/body-large :class (stl/css :detail-title)}
(:label conflict)]
(if resolved?
[:> tag* {:tone "success"} (tr "workspace.branches.resolve.decided")]
[:> tag* {:tone "warning"}
(if per-attr?
(dm/str (tr "workspace.branches.resolve.tag.clashes" (i18n/c clash-n))
(when (pos? indep-n)
(dm/str " - " (tr "workspace.branches.resolve.tag.independent" (i18n/c indep-n)))))
(tr (get reason->label (:reason conflict) "workspace.branches.status.conflict")))])]
;; what each side looks like comes first: the previews, or the two
;; versions to pick from when the entity cannot be split by property
(cond
(not per-attr?)
[:> whole-choice* {:conflict conflict :res res
:main-detail main-detail :branch-detail branch-detail
:on-choose on-choose}]
text?
[:> text-choice* {:conflict conflict :res res
:main-detail main-detail :branch-detail branch-detail
:on-pick on-pick}]
:else
[:div {:class (stl/css :cards)}
[:> preview-card* {:label (tr "workspace.branches.review.side.main")
:side "main" :detail main-detail}
[:> brp/entity-preview* {:kind (:kind conflict)
:value (:main conflict)
:missing {:reason :deleted :side :main}}]]
[:> preview-card* {:label (tr "workspace.branches.review.side.branch")
:side "branch" :detail branch-detail}
[:> brp/entity-preview* {:kind (:kind conflict)
:value (:branch conflict)
:missing {:reason :deleted :side :branch}}]]
[:> preview-card* {:label (tr "workspace.branches.review.side.result")
:side "result"}
[:> brp/entity-preview* {:kind (:kind conflict) :value result}]]])
(when (and per-attr? (seq table-rows))
[:> attr-table* {:rows table-rows :res res :on-pick on-pick}])
(when per-attr?
(for [{:keys [attr main branch source]} rows
:when (and (= :clash source) (not= attr :content))
:let [choice (brc/attr-choice res attr)]]
[:div {:class (stl/css :inline-actions) :key (str attr)}
[:> text* {:as "span" :typography t/body-small :class (stl/css :muted)}
(dm/str (brc/attr-label attr) ":")]
[:> button* {:variant (if (= choice :main) "primary" "secondary")
:on-click #(on-pick attr :main)}
(tr "workspace.branches.resolve.keep-main" (brc/display-val main))]
[:> button* {:variant (if (= choice :branch) "primary" "secondary")
:on-click #(on-pick attr :branch)}
(tr "workspace.branches.resolve.keep-branch" (brc/display-val branch))]]))
;; once decided, one quiet block says what the merge leaves behind,
;; with the way back; until then, a note says how the choice works
(if resolved?
[:div {:class (stl/css :outcome)}
[:div {:class (stl/css :outcome-text)}
[:> text* {:as "span" :typography t/body-small :class (stl/css :outcome-title)}
(tr "workspace.branches.resolve.drops.title")]
(if (seq drops)
(for [{:keys [attr side value]} drops]
[:> text* {:key (str attr)
:as "span"
:typography t/body-small
:class (stl/css :outcome-line)}
(tr (if (= side :main)
"workspace.branches.resolve.drops.main-short"
"workspace.branches.resolve.drops.branch-short")
(str/lower (brc/attr-label attr)))
" "
[:span {:class (stl/css :outcome-dropped)} (brc/display-val value)]])
[:> text* {:as "span" :typography t/body-small :class (stl/css :outcome-line)}
(tr "workspace.branches.resolve.drops.none")])]
[:> button* {:variant "ghost" :on-click on-clear}
(tr "workspace.branches.resolve.change-choice")]]
[:div {:class (stl/css :footnote)}
[:> i/icon* {:icon-id i/info :size "s" :class (stl/css :footnote-icon)}]
[:> text* {:as "p" :typography t/body-small :class (stl/css :footnote-text)}
(cond
text? (tr "workspace.branches.resolve.intro-text")
per-attr? (tr "workspace.branches.resolve.intro-attrs")
:else (tr "workspace.branches.resolve.intro-whole"))]])]))
;; --- The dialog
(mf/defc branch-conflicts-dialog*
{::mf/register modal/components
::mf/register-as :branch-conflicts}
[{:keys [branch mode conflicts excluded]}]
(let [{:keys [diff resolutions status]} (mf/deref branch-diff)
loading? (= status :loading)
keep?* (mf/use-state false)
keep? (deref keep?*)
selected* (mf/use-state 0)
selected (deref selected*)
diff-meta (:meta diff)
;; the command that opened this modal returned its own conflict set,
;; computed in the id space it resolves against; the diff this dialog
;; can fetch may have been computed in another id space and disagree,
;; which left the dialog showing 0 of 0 with an Apply that could never
;; enable. Prefer what the command said.
conflicts (if (seq conflicts) conflicts (:conflicts diff))
;; the payload's two sides are document-keyed in both directions:
;; `:main` holds main's value and `:branch` the branch's
;; (`branch-merge.cljc::compute-merge`), which is what every heading,
;; chip and button below names. Resolutions use the same vocabulary.
resolutions (or resolutions {})
total (count conflicts)
resolved? (fn [c] (bm/conflict-resolved? c (get resolutions (:id c))))
resolved (count (filterv resolved? conflicts))
pending (- total resolved)
all-done? (and (pos? total) (zero? pending))
sel-idx (min selected (max 0 (dec total)))
sel (when (seq conflicts) (nth conflicts sel-idx))
sel-res (when sel (get resolutions (:id sel)))
on-close (mf/use-fn #(st/emit! (modal/hide)))
on-back (mf/use-fn (mf/deps branch)
#(modal/show! :branch-compare {:branch branch}))
on-select (mf/use-fn #(reset! selected* %))
on-pick (mf/use-fn
(mf/deps sel sel-res)
(fn [attr side]
(when sel
(st/emit! (dwb/set-conflict-resolutions
{(:id sel) (brc/choose-attr sel sel-res attr side)})))))
on-choose (mf/use-fn
(mf/deps sel)
(fn [side]
(when sel
(st/emit! (dwb/set-conflict-resolution (:id sel) side)))))
on-clear (mf/use-fn
(mf/deps sel)
(fn []
(when sel
(st/emit! (dwb/clear-conflict-resolution (:id sel))
(dwb/set-conflict-resolutions
(if (brc/per-attr? sel)
{(:id sel) (brc/prefill-resolution sel)}
{}))))))
on-all (mf/use-fn
(mf/deps conflicts)
(fn [side]
(st/emit! (dwb/set-conflict-resolutions
(into {} (map (fn [c] [(:id c) (brc/choose-side c side)]))
conflicts)))))
on-next (mf/use-fn
(mf/deps conflicts sel-idx resolutions)
(fn []
(let [n (count conflicts)
order (map #(mod (+ sel-idx 1 %) n) (range n))
next-ix (some #(when-not (bm/conflict-resolved?
(nth conflicts %)
(get resolutions (:id (nth conflicts %))))
%)
order)]
(when next-ix (reset! selected* next-ix)))))
on-apply (mf/use-fn
(mf/deps branch resolutions mode keep? excluded conflicts)
(fn []
(let [ids (into #{} (map :id) conflicts)
res (into {} (filter (fn [[id _]] (contains? ids id))) resolutions)]
(st/emit! (if (= mode :update)
(dwb/update-branch-from-main branch {:resolutions res
:excluded excluded})
(dwb/merge-branch branch {:resolutions res
:keep-branch keep?
:excluded excluded}))))))]
;; the fetched diff backs both the fallback conflict set and the side
;; subtitles, so it must be computed in the direction this modal resolves
(mf/with-effect [(:id branch) mode]
(st/emit! (dwb/fetch-branch-diff (:id branch)
(if (= mode :update) :main->branch :branch->main))))
;; independent edits are not in doubt: fill them in from the side that
;; made them, so only the clashing properties wait for a choice
(mf/with-effect [conflicts status]
(when (and (seq conflicts) (not loading?))
(let [seed (into {}
(keep (fn [c]
(when (and (brc/per-attr? c)
(nil? (get resolutions (:id c))))
[(:id c) (brc/prefill-resolution c)])))
conflicts)]
(when (seq seed)
(st/emit! (dwb/set-conflict-resolutions seed))))))
[:> brl/review-modal*
{:icon i/triangle-alert
:tone :warning
:title (tr "workspace.branches.resolve.title")
:route (mf/html [:> brl/route* {:branch-name (:name branch)
:incoming (= mode :update)}])
:filters (mf/html
[:> filter-chip* {:icon i/triangle-alert
:label (dm/str (tr "workspace.branches.review.category.decisions")
" (" total ")")
:tone "warning"
:is-selected true}])
:list (when (and (not loading?) (seq conflicts))
(mf/html
[:*
;; deciding them all at once only means something with
;; more than one decision
(when (> total 1)
[:div {:class (stl/css :bulk)}
[:> button* {:variant "secondary" :on-click #(on-all :main)}
(tr "workspace.branches.resolve.keep-all-main")]
[:> button* {:variant "secondary" :on-click #(on-all :branch)}
(tr "workspace.branches.resolve.keep-all-branch")]])
[:> brl/group-head* {:label (tr "workspace.branches.review.group.decisions" (str total))
:is-open true}]
(for [[idx c] (map-indexed vector conflicts)]
[:> conflict-row* {:key idx
:conflict c
:index idx
:selected sel-idx
:res (get resolutions (:id c))
:on-select on-select}])]))
:detail
(mf/html
(cond
loading?
[:div {:class (stl/css :empty)}
[:> empty-state* {:icon i/git-merge :text (tr "workspace.branches.compare.loading")}]]
(empty? conflicts)
[:div {:class (stl/css :empty)}
[:> empty-state* {:icon i/git-merge :text (tr "workspace.branches.conflicts.empty")}]]
sel
[:> conflict-detail* {:key (str (:id sel))
:conflict sel
:res sel-res
:diff-meta diff-meta
:on-pick on-pick
:on-choose on-choose
:on-clear on-clear}]))
:footer-start
(mf/html
(if all-done?
[:div {:class (stl/css :footer-start)}
[:> text* {:as "span" :typography t/body-small :class (stl/css :footer-ready)}
(tr "workspace.branches.conflicts.footer-ready")]
(when (not= mode :update)
[:> checkbox* {:id "conflicts-archive-branch"
:label (tr "workspace.branches.merge.archive-label")
:checked keep?
:on-change #(swap! keep?* not)}])]
[:> text* {:as "span" :typography t/body-small :class (stl/css :footer-blocked)}
(tr "workspace.branches.resolve.footer.blocked" (i18n/c pending))]))
:footer-end
(mf/html
[:*
[:> button* {:variant "secondary" :on-click on-back}
(tr "workspace.branches.resolve.back")]
(if all-done?
[:> button* {:variant "primary" :icon i/git-merge :on-click on-apply}
(tr "workspace.branches.conflicts.apply")]
[:> button* {:variant "primary" :on-click on-next}
(tr "workspace.branches.resolve.next")])])
:on-close on-close}]))

View File

@ -0,0 +1,336 @@
// 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/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/mixins.scss" as *;
.empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
}
.bulk {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-xs);
margin-block-end: var(--sp-xs);
}
.detail-head {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-s);
}
.detail-title {
color: var(--color-foreground-primary);
overflow-wrap: anywhere;
}
.muted {
color: var(--color-foreground-secondary);
}
.outcome {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--sp-m);
padding: var(--sp-s) var(--sp-s) var(--sp-s) var(--sp-m);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
}
.outcome-text {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--sp-xxs);
min-inline-size: 0;
}
.outcome-title {
color: var(--color-foreground-primary);
}
.outcome-line {
color: var(--color-foreground-secondary);
}
// only the value that is lost takes the error colour
.outcome-dropped {
color: var(--color-accent-error);
}
.footnote {
display: flex;
flex-shrink: 0;
align-items: flex-start;
gap: var(--sp-xs);
margin-block-start: auto;
padding-block-start: var(--sp-s);
color: var(--color-foreground-secondary);
}
.footnote-icon {
flex-shrink: 0;
}
.footnote-text {
margin: 0;
}
// --- Property table
.table {
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
}
.table-row {
--table-row-bg-color: transparent;
display: grid;
grid-template-columns: minmax(0, 1.2fr) repeat(3, minmax(0, 1fr)) px2rem(100);
align-items: center;
gap: var(--sp-s);
min-block-size: $sz-40;
padding: var(--sp-xs) var(--sp-m);
background-color: var(--table-row-bg-color);
&.is-head {
--table-row-bg-color: var(--color-background-secondary);
}
&.is-clash {
--table-row-bg-color: var(--color-background-warning);
}
}
.th {
color: var(--color-foreground-secondary);
}
.td-name {
@include text-ellipsis;
color: var(--color-foreground-primary);
font-weight: 500;
}
.td-unchanged {
color: var(--color-foreground-secondary);
}
// the button hugs its value chip, and the selection outline sits right
// on the chip's own border instead of around the cell
.td-pick {
display: inline-flex;
justify-self: start;
min-inline-size: 0;
max-inline-size: 100%;
padding: 0;
border: none;
border-radius: $br-4;
background: none;
cursor: pointer;
&:disabled {
cursor: default;
}
&:not(:disabled):hover,
&.is-chosen {
outline: $b-1 solid var(--color-accent-primary);
outline-offset: calc(#{$b-1} * -1);
}
}
.td-result {
display: inline-flex;
justify-self: start;
min-inline-size: 0;
max-inline-size: 100%;
}
.td-choose {
color: var(--color-accent-warning);
}
.td-tag {
display: flex;
justify-content: flex-end;
}
.inline-actions {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-s);
}
.cards {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-m);
}
// --- Version cards
.options {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-m);
}
.option {
--option-border-color: var(--color-background-quaternary);
display: flex;
flex-direction: column;
gap: var(--sp-s);
padding: var(--sp-m);
border: $b-1 solid var(--option-border-color);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
color: var(--color-foreground-primary);
text-align: start;
cursor: pointer;
&:hover {
--option-border-color: var(--color-foreground-secondary);
}
&.is-chosen {
--option-border-color: var(--color-accent-primary);
}
&:focus-visible {
outline: $b-1 solid var(--color-accent-primary);
}
}
.option-head {
display: flex;
align-items: center;
gap: var(--sp-s);
}
.option-radio {
flex-shrink: 0;
inline-size: $sz-16;
block-size: $sz-16;
border: $b-2 solid var(--color-foreground-secondary);
border-radius: $br-circle;
&.is-chosen {
border: px2rem(4) solid var(--color-accent-primary);
}
}
.option-label {
font-weight: 600;
}
.option-side {
display: flex;
align-items: center;
gap: var(--sp-xs);
}
.option-side-label {
color: var(--color-accent-primary);
&.is-main {
color: var(--color-accent-secondary);
}
}
.option-detail {
color: var(--color-foreground-secondary);
}
.option-stage {
display: flex;
align-items: center;
justify-content: center;
block-size: var(--preview-card-stage-block-size, #{px2rem(100)});
padding: var(--sp-s);
border-radius: $br-6;
// the same checkerboard as `preview-card*`
background-color: var(--color-static-white);
background-image: repeating-conic-gradient(
color-mix(in srgb, var(--color-canvas) 70%, var(--color-static-white)) 0% 25%,
transparent 0% 50%
);
background-size: px2rem(20) px2rem(20);
}
.option-warning {
display: flex;
align-items: center;
gap: var(--sp-xs);
color: var(--color-accent-error);
}
.paragraphs {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.paragraph {
--paragraph-bg-color: transparent;
padding: var(--sp-s);
border-radius: $br-4;
background-color: var(--paragraph-bg-color);
color: var(--color-foreground-primary);
&.is-kept {
--paragraph-bg-color: var(--color-background-success);
}
&.is-dropped {
--paragraph-bg-color: var(--color-background-error);
}
}
// --- Footer
.footer-start {
display: flex;
align-items: center;
gap: var(--sp-l);
}
.footer-ready {
color: var(--color-accent-success);
}
.footer-blocked {
color: var(--color-accent-warning);
}
.row-warning {
color: var(--color-accent-warning);
}

View File

@ -0,0 +1,204 @@
;; 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.branch-review.facts
"What the detail pane says about an entry that has no before and after:
a new or deleted page or layer, and the library entries other layers
depend on. It answers what the entry is, where it lands and what it
affects, from the open file."
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.product.preview-card :refer [preview-card*]]
[app.main.ui.workspace.sidebar.branch-review.common :as brc]
[app.main.ui.workspace.sidebar.branch-review.layout :as brl]
[app.main.ui.workspace.sidebar.branch-review.previews :as brp]
[app.util.i18n :as i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private max-names
"How many board names a page lists before it counts the rest."
6)
(defn- side-label
[side]
(tr (if (= side :main)
"workspace.branches.review.side.main"
"workspace.branches.review.side.branch")))
(mf/defc fact-list*
"Label and value rows, one per fact."
{::mf/private true}
[{:keys [rows]}]
[:div {:class (stl/css :facts)}
(for [[label value] (remove nil? rows)]
[:div {:class (stl/css :fact) :key label}
[:> text* {:as "span" :typography t/body-small :class (stl/css :fact-label)} label]
[:div {:class (stl/css :fact-value)} value]])])
(mf/defc section-title*
{::mf/private true}
[{:keys [children]}]
[:> text* {:as "span" :typography t/body-small :class (stl/css :section-title)}
children])
;; --- Pages
(mf/defc page-facts*
"A new or deleted page: what it holds, drawn and counted, and the
changes listed on it (`children`)."
[{:keys [item data open-side incoming change-count children]}]
(let [page (get-in data [:pages-index (:id item)])
deleted? (= :deleted (:status item))
{:keys [boards layers]} (when page (brc/page-contents page))
names (map :name boards)]
[:*
(if page
[:*
[:> preview-card* {:label (side-label open-side)
:side (name open-side)
:detail (:name page)}
[:> brp/page-preview* {:objects (:objects page)}]]
[:> fact-list*
{:rows [[(tr "workspace.branches.review.facts.contents")
(if (zero? layers)
(tr "workspace.branches.review.facts.empty-page")
(dm/str (tr "workspace.branches.review.facts.boards" (i18n/c (count boards)))
" - "
(tr "workspace.branches.review.facts.layers" (i18n/c layers))))]
(when (seq names)
[(tr "workspace.branches.review.facts.board-names")
(dm/str (str/join ", " (take max-names names))
(when (> (count names) max-names)
(dm/str " " (tr "workspace.branches.review.summary.more"
(i18n/c (- (count names) max-names))))))])]}]
(when (and deleted? (pos? layers))
[:> brl/notice* {:level :warning
:title (tr "workspace.branches.review.facts.page-goes")}
(tr (if incoming
"workspace.branches.review.facts.page-goes-update"
"workspace.branches.review.facts.page-goes-merge")
(tr "workspace.branches.review.facts.boards" (i18n/c (count boards)))
(tr "workspace.branches.review.facts.layers" (i18n/c layers)))])]
[:> preview-card* {:label (side-label (if (= open-side :main) :branch :main))
:side (if (= open-side :main) "branch" "main")}
[:> brp/entity-preview* {:kind :page
:value nil
:missing {:reason :unavailable
:side (if (= open-side :main) :branch :main)
:open-side open-side}}]])
(when (pos? change-count)
[:div {:class (stl/css :section)}
[:> section-title* {}
(tr "workspace.branches.review.facts.changes-on-page" (str change-count))]
[:div {:class (stl/css :inner-list)} children]])]))
;; --- Layers
(defn- shape-rows
"The facts that say what a layer is: its type, size, place, fill, text
and the component it copies."
[shape data type-label]
(let [{:keys [width height x y fills content component-id]} shape
fill (some :fill-color fills)
text (first (remove str/blank? (brc/content-paragraphs content)))
component (when (and component-id (not (:main-instance shape)))
(get-in data [:components component-id :name]))]
(cond-> []
type-label
(conj [(tr "workspace.branches.review.facts.type") type-label])
(and (number? width) (number? height))
(conj [(tr "workspace.branches.review.facts.size")
(dm/str (brc/display-val width) " × " (brc/display-val height))])
(and (number? x) (number? y))
(conj [(tr "workspace.branches.review.facts.position")
(dm/str (brc/display-val x) ", " (brc/display-val y))])
fill
(conj [(tr "workspace.branches.review.attr.fill")
(mf/html [:> brl/value-chip* {:value fill}])])
text
(conj [(tr "workspace.branches.review.attr.text") (brc/display-val text)])
component
(conj [(tr "workspace.branches.compare.attr.component") component]))))
(mf/defc shape-facts*
"A new or deleted layer: where it lands (or was), what it is and, for a
deleted main component, the copies it leaves behind."
[{:keys [item data incoming]}]
(let [objects (get-in data [:pages-index (:page-id item) :objects])
shape (get objects (:id item))
page (get-in data [:pages-index (:page-id item) :name])
deleted? (= :deleted (:status item))
type-lbl (some-> (brc/type-label-key item) tr)
copies (when (and deleted? shape (:main-instance shape))
(brc/component-copies data (:component-id shape)))]
(when shape
[:*
[:> fact-list*
{:rows (into [[(tr (if deleted?
"workspace.branches.review.facts.where-was"
"workspace.branches.review.facts.where-lands"))
(str/join " › " (cons page (brc/shape-path objects (:id item))))]]
(shape-rows shape data type-lbl))}]
(when (and copies (pos? copies))
[:> brl/notice* {:level :warning
:title (tr "workspace.branches.review.facts.copies-orphaned-title")}
(tr (if incoming
"workspace.branches.review.facts.copies-orphaned-update"
"workspace.branches.review.facts.copies-orphaned-merge")
(i18n/c copies))])])))
;; --- Library entries other layers depend on
(defn- usages
[item data]
(case (:kind item)
:component (brc/component-copies data (:id item))
:color (brc/color-usages data (:id item))
:typography (brc/typography-usages data (:id item))
:token (brc/token-usages data (:label item))
nil))
(mf/defc usage-facts*
"How many layers of the open file depend on a component, colour,
typography or token, and what a merge does to them when it deletes
it."
[{:keys [item data]}]
(let [n (usages item data)
deleted? (= :deleted (:status item))
kind (:kind item)]
(when (some? n)
(cond
(and deleted? (pos? n))
[:> brl/notice* {:level :warning
:title (tr "workspace.branches.review.facts.used-title" (i18n/c n))}
(tr (if (= kind :component)
"workspace.branches.review.facts.deleted-component-used"
"workspace.branches.review.facts.deleted-used")
(i18n/c n))]
deleted?
[:> brl/notice* {:level :success
:title (tr "workspace.branches.review.facts.unused-title")}
(tr "workspace.branches.review.facts.deleted-unused")]
:else
[:> fact-list*
{:rows [[(tr "workspace.branches.review.facts.used-by")
(tr (if (= kind :component)
"workspace.branches.review.facts.copies"
"workspace.branches.review.facts.layers")
(i18n/c n))]]}]))))

View File

@ -0,0 +1,58 @@
// 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/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/mixins.scss" as *;
@use "ds/typography.scss" as *;
.facts {
flex-shrink: 0;
display: flex;
flex-direction: column;
padding-block: var(--sp-xs);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
}
.fact {
display: grid;
grid-template-columns: px2rem(180) 1fr;
align-items: center;
gap: var(--sp-s);
min-block-size: $sz-32;
padding: var(--sp-xs) var(--sp-m);
}
.fact-label {
color: var(--color-foreground-secondary);
}
.fact-value {
@include use-typography("body-small");
@include text-ellipsis;
color: var(--color-foreground-primary);
}
.section {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--sp-s);
}
.section-title {
color: var(--color-foreground-primary);
}
.inner-list {
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
}

View File

@ -0,0 +1,141 @@
;; 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.branch-review.layout
"The frame the compare and conflict dialogs share: a header with the
route between the two branches, a bar of filters, an
optional banner, a list beside a detail pane (or a single pane), and a
footer with a status line and the actions."
(:require-macros [app.main.style :as stl])
(:require
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.layout.modal :refer [modal* modal-header* modal-footer*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.ds.product.branch-pill :refer [branch-pill*]]
[app.main.ui.workspace.sidebar.branch-review.common :as brc]
[app.main.ui.workspace.sidebar.pull-requests :as prui]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(mf/defc route*
"`from → to` between the branch and main. With `on-swap` the arrow
turns the comparison around."
[{:keys [branch-name is-review incoming on-swap]}]
(let [branch (mf/html [:> branch-pill* {:label branch-name :is-review (boolean is-review)}])
main (mf/html [:> branch-pill* {:label (tr "workspace.branches.main") :is-main true}])
arrow (if on-swap
(mf/html
[:button {:class (stl/css :route-swap)
:title (tr "workspace.branches.compare.swap")
:aria-label (tr "workspace.branches.compare.swap")
:on-click on-swap}
[:> i/icon* {:icon-id i/arrow-long-right :size "s"}]])
(mf/html
[:span {:class (stl/css :route-arrow) :aria-hidden true}
[:> i/icon* {:icon-id i/arrow-long-right :size "s"}]]))]
[:div {:class (stl/css :route)}
(if incoming
[:* main arrow branch]
[:* branch arrow main])]))
(mf/defc review-modal*
"`list` and `detail` are the two panes; with no `list` the `detail`
takes the whole width. `filters` go in the bar under the header,
after the \"This branch:\" label; `banner` sits under that bar."
[{:keys [icon tone title route filters banner list detail
footer-start footer-end on-close]}]
[:> modal* {:is-open true
:on-open-change (prui/on-dismiss on-close)
:size "xlarge"
:show-body false
:class (stl/css :review-modal)}
[:> modal-header* {:class (stl/css :review-header)}
[:div {:class (stl/css-case :header-icon true
:is-warning (= tone :warning))}
[:> i/icon* {:icon-id icon}]]
[:div {:class (stl/css :header-text)}
[:> heading* {:level 2
:typography t/body-large
:class (stl/css :header-title)}
title]
route]]
(when filters
[:div {:class (stl/css :filter-bar)}
[:> text* {:as "span" :typography t/body-small :class (stl/css :filter-label)}
(tr "workspace.branches.review.this-branch")]
filters])
banner
(if list
[:div {:class (stl/css :review-body)}
[:div {:class (stl/css :review-list)} list]
[:div {:class (stl/css :review-detail)} detail]]
[:div {:class (stl/css :review-body :is-single)}
[:div {:class (stl/css :review-detail)} detail]])
[:> modal-footer* {:variant "split"
:class (stl/css :review-footer)
:start footer-start
:end footer-end}]])
(mf/defc group-head*
"The heading of a group in the change list: a fold toggle, the group
name and count, and an optional control before them (a checkbox)."
[{:keys [label is-open on-toggle start end]}]
[:div {:class (stl/css :group-head)}
start
[:button {:class (stl/css :group-toggle)
:aria-expanded (boolean is-open)
:on-click on-toggle}
[:> i/icon* {:icon-id (if is-open i/arrow-down i/arrow-right)
:size "s"
:class (stl/css :group-chevron)}]
[:> text* {:as "span" :typography t/body-small :class (stl/css :group-label)}
label]]
end])
(mf/defc notice*
"A note in the detail pane: the DS contextual notification, with an
optional title leading the sentence, in the small body type of the
rest of the pane."
[{:keys [level title children]}]
[:> context-notification* {:level level :class (stl/css :notice)}
[:> text* {:as "span" :typography t/body-small}
(when title
[:span {:class (stl/css :notice-title)} title])
(when title " ")
children]])
(mf/defc value-chip*
"A property value in a framed box, with a swatch when it is a colour.
`is-old` draws the value the change replaced, quieter."
[{:keys [value is-old]}]
(let [hex (brc/hex-color value)]
[:span {:class (stl/css-case :value-chip true :is-old is-old)}
(when hex
[:span {:class (stl/css :value-swatch)
:style {:background-color hex}}])
(if (and hex (not (sequential? value)))
hex
(brc/display-val value))]))
(mf/defc value-steps*
"Each property of a change as `before → after`, one row per property."
[{:keys [attrs]}]
[:div {:class (stl/css :steps)}
(for [[attr {:keys [main branch]}] attrs]
[:div {:class (stl/css :steps-row) :key (str attr)}
[:> text* {:as "span" :typography t/body-small :class (stl/css :steps-name)}
(brc/attr-label attr)]
[:div {:class (stl/css :steps-values)}
[:> value-chip* {:value main :is-old true}]
[:> i/icon* {:icon-id i/arrow-long-right :size "s" :class (stl/css :steps-arrow)}]
[:> value-chip* {:value branch}]]])])

View File

@ -0,0 +1,265 @@
// 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/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/mixins.scss" as *;
@use "ds/typography.scss" as *;
// The dialog draws its own rows edge to edge (header, filter bar, panes,
// footer), so it opts out of the padded `modal*` body.
//
// These dialogs carry more to read than a `modal*` size allows, so they
// size to the window instead, within bounds:
// - width: 88% of the window, between 960 and 1440, and never wider than
// the window less a margin; a 4K screen stops at the upper bound;
// - panes: all the height the header, filters, footer and margins leave,
// up to 880. The height stays fixed for a given window, so moving
// between changes or decisions never resizes the dialog; the panes
// scroll instead.
// The previews grow with the panes too.
// Bounds are in rem, so the dialog follows the interface scale.
.review-modal {
--review-modal-inline-size: clamp(#{px2rem(960)}, 88vw, #{px2rem(1440)});
--review-panes-block-size: min(calc(100dvh - #{px2rem(280)}), #{px2rem(880)});
--review-list-inline-size: clamp(#{px2rem(320)}, 26%, #{px2rem(400)});
// the previews grow with the panes: about a third of their height in
// the detail, about two thirds when expanded, within bounds
--preview-card-stage-block-size: clamp(
#{px2rem(100)},
calc(var(--review-panes-block-size) * 0.3),
#{px2rem(260)}
);
--preview-card-stage-large-block-size: clamp(
#{px2rem(320)},
calc(var(--review-panes-block-size) * 0.62),
#{px2rem(600)}
);
max-inline-size: min(var(--review-modal-inline-size), calc(100vw - #{px2rem(32)}));
overflow: hidden;
}
.review-header {
gap: var(--sp-m);
padding: var(--sp-xl) var(--sp-xxxl) var(--sp-xl) var(--sp-xl);
border-block-end: $b-1 solid var(--color-background-quaternary);
}
.header-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
inline-size: $sz-32;
block-size: $sz-32;
border-radius: $br-8;
color: var(--color-foreground-secondary);
background-color: var(--color-background-tertiary);
&.is-warning {
color: var(--color-accent-warning);
}
}
.header-text {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--sp-xs);
min-inline-size: 0;
}
.header-title {
color: var(--color-foreground-primary);
}
.route {
display: flex;
align-items: center;
gap: var(--sp-xs);
min-inline-size: 0;
}
.route-arrow {
display: flex;
color: var(--color-foreground-secondary);
}
.route-swap {
--route-swap-color: var(--color-foreground-secondary);
display: flex;
align-items: center;
padding: var(--sp-xxs);
border: none;
border-radius: $br-4;
background: none;
color: var(--route-swap-color);
cursor: pointer;
&:hover {
--route-swap-color: var(--color-accent-primary);
}
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s) var(--sp-xl);
background-color: var(--color-background-secondary);
}
.filter-label {
color: var(--color-foreground-secondary);
}
.review-body {
display: grid;
grid-template-columns: var(--review-list-inline-size) 1fr;
block-size: var(--review-panes-block-size);
min-block-size: 0;
&.is-single {
grid-template-columns: 1fr;
}
}
.review-list {
@include custom-scrollbar;
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
padding: var(--sp-m);
overflow-block: auto;
border-inline-end: $b-1 solid var(--color-background-quaternary);
}
.review-detail {
@include custom-scrollbar;
display: flex;
flex-direction: column;
gap: var(--sp-l);
min-inline-size: 0;
padding: var(--sp-xl);
overflow-block: auto;
}
.review-footer {
padding: var(--sp-l) var(--sp-xl);
border-block-start: $b-1 solid var(--color-background-quaternary);
}
.group-head {
display: flex;
align-items: center;
gap: var(--sp-s);
padding-block: var(--sp-s);
padding-inline: var(--sp-s) px2rem(10);
}
.group-toggle {
display: flex;
flex: 1;
align-items: center;
gap: var(--sp-s);
min-inline-size: 0;
padding: 0;
border: none;
background: none;
color: var(--color-foreground-primary);
cursor: pointer;
}
.group-chevron {
flex-shrink: 0;
color: var(--color-foreground-secondary);
}
.group-label {
@include text-ellipsis;
}
.notice {
flex-shrink: 0;
}
.notice-title {
font-weight: 600;
}
.value-chip {
--value-chip-border-color: var(--color-foreground-primary);
@include use-typography("body-small");
display: inline-flex;
align-items: center;
gap: var(--sp-xs);
max-inline-size: 100%;
padding: var(--sp-xxs) var(--sp-s);
overflow: hidden;
border: $b-1 solid var(--value-chip-border-color);
border-radius: $br-4;
background-color: var(--color-background-secondary);
color: var(--color-foreground-primary);
white-space: nowrap;
text-overflow: ellipsis;
&.is-old {
--value-chip-border-color: var(--color-foreground-secondary);
}
}
.value-swatch {
flex-shrink: 0;
inline-size: $sz-12;
block-size: $sz-12;
border-radius: $br-4;
}
.steps {
flex-shrink: 0;
display: flex;
flex-direction: column;
padding-block: var(--sp-xs);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
}
.steps-row {
display: grid;
grid-template-columns: px2rem(180) 1fr;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s) var(--sp-m);
}
.steps-name {
@include text-ellipsis;
color: var(--color-foreground-primary);
font-weight: 500;
}
.steps-values {
display: flex;
align-items: center;
gap: var(--sp-s);
min-inline-size: 0;
}
.steps-arrow {
flex-shrink: 0;
color: var(--color-foreground-secondary);
}

View File

@ -0,0 +1,224 @@
;; 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.branch-review.previews
"Visual previews of the two sides of a change. The diff carries whole
entities only for conflicts; for a clean change the open file holds one
side and the changed attrs give the other, which is enough to draw both
the layer and the board it sits in."
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.main.render :as render]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.workspace.sidebar.branch-review.common :as brc]
[app.util.color :as uc]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(def ^:private renderable-shape-types
"Shape types we can faithfully render to SVG from a shape map. Images /
svg-raw reference (possibly cross-file) media and fall back to a type
icon instead."
#{:rect :circle :frame :path :bool :text :group})
(defn open-side
"Which document the open file is for `branch`: `:branch` when it is the
branch file, `:main` otherwise."
[branch file-id]
(if (and (some? file-id) (= file-id (:branch-file-id branch)))
:branch
:main))
(defn side-entity
"One side (`:main` or `:branch`) of diff entry `item`. A conflict carries
both; a clean change carries the attrs that differ, so the side the open
file does not hold is the open entity with those attrs swapped in. An
entry that does not exist on a side (added on the other one, or
deleted) has no entity there."
[item side open-side open-entity]
(cond
(= :conflict (:status item))
(get item side)
(= side open-side)
open-entity
(nil? open-entity)
nil
(and (= :added (:status item)) (= side :main))
nil
(and (= :deleted (:status item)) (= side :branch))
nil
:else
(reduce-kv (fn [entity attr values]
(let [v (get values side)]
(if (nil? v)
(dissoc entity attr)
(assoc entity attr v))))
open-entity
(:changed-attrs item))))
(defn side-objects
"The page `objects` as they stand on `side`, with every shape change of
`items` that lives on this page put on that side. A shape missing on
that side is taken out of its parent, so boards draw without it."
[objects items side open-side]
(reduce
(fn [objects item]
(if (not= :shape (:kind item))
objects
(let [id (:id item)
entity (side-entity item side open-side (get objects id))]
(if (some? entity)
(assoc objects id entity)
(let [parent-id (:parent-id (get objects id))]
(cond-> (dissoc objects id)
(contains? objects parent-id)
(update-in [parent-id :shapes] (fn [ids] (filterv #(not= id %) ids)))))))))
objects
items))
(defn- shape-bounds
"Axis-aligned bounds framing a shape, taken from its rotated corner
`:points` when present, else its `:selrect`."
[shape]
(let [pts (:points shape)]
(if (and (sequential? pts) (seq pts))
(let [xs (keep :x pts) ys (keep :y pts)
x (apply min xs) y (apply min ys)]
{:x x :y y :width (- (apply max xs) x) :height (- (apply max ys) y)})
(let [sr (:selrect shape)]
{:x (:x sr 0) :y (:y sr 0) :width (:width sr 1) :height (:height sr 1)}))))
(mf/defc shape-preview*
"Renders `shape` to a fitted SVG with the real shape renderers. `objects`
supplies its children (a board or group draws them)."
{::mf/private true}
[{:keys [shape objects]}]
(let [{:keys [x y width height]} (shape-bounds shape)
width (max 1 width)
height (max 1 height)
objects (mf/with-memo [shape objects]
(assoc (or objects {}) (:id shape) shape))
wrapper (mf/with-memo [objects] (render/shape-wrapper-factory objects))]
[:svg {:class (stl/css :preview-svg)
:viewBox (dm/str x " " y " " width " " height)
:preserveAspectRatio "xMidYMid meet"
:xmlns "http://www.w3.org/2000/svg"
:fill "none"}
[:> wrapper {:shape shape}]]))
(mf/defc page-preview*
"The whole page `objects` drawn to fit: every top-level layer, framed
together."
[{:keys [objects]}]
(let [tops (mf/with-memo [objects]
(into [] (keep #(get objects %)) (:shapes (get objects uuid/zero))))
bounds (mf/with-memo [tops]
(when (seq tops)
(let [bs (map shape-bounds tops)
x1 (apply min (map :x bs))
y1 (apply min (map :y bs))
x2 (apply max (map #(+ (:x %) (:width %)) bs))
y2 (apply max (map #(+ (:y %) (:height %)) bs))]
{:x x1 :y y1 :width (max 1 (- x2 x1)) :height (max 1 (- y2 y1))})))
wrapper (mf/with-memo [objects] (render/shape-wrapper-factory objects))]
(if bounds
(let [{:keys [x y width height]} bounds]
[:svg {:class (stl/css :preview-svg)
:viewBox (dm/str x " " y " " width " " height)
:preserveAspectRatio "xMidYMid meet"
:xmlns "http://www.w3.org/2000/svg"
:fill "none"}
(for [shape tops]
[:> wrapper {:key (str (:id shape)) :shape shape}])])
[:div {:class (stl/css :preview-empty)}
[:> i/icon* {:icon-id i/document :size "s"}]])))
(defn missing-reason
"Why `side` of diff entry `item` has nothing to draw. `:absent` when the
entry never existed there (the other side added it), `:deleted` when
that side deleted it, `:unavailable` when it exists there but the open
file cannot draw it. `incoming` says the listed changes are main's."
[item side incoming]
(let [origin (if incoming :main :branch)]
(case (:status item)
:conflict :deleted
:added (if (= side origin) :unavailable :absent)
:deleted (if (= side origin) :deleted :unavailable)
:unavailable)))
(mf/defc missing-preview*
"The empty stage of a side with nothing to draw, saying why."
{::mf/private true}
[{:keys [reason side open-side]}]
(let [main? (= side :main)
[icon title hint]
(case reason
:absent
[i/add
(tr (if main? "workspace.branches.review.missing.not-in-main"
"workspace.branches.review.missing.not-in-branch"))
(tr (if main? "workspace.branches.review.missing.new-in-branch"
"workspace.branches.review.missing.new-in-main"))]
:deleted
[i/delete
(tr (if main? "workspace.branches.review.missing.deleted-in-main"
"workspace.branches.review.missing.deleted-in-branch"))
nil]
[i/eye-off
(tr "workspace.branches.review.missing.no-preview")
(when (and open-side (not= side open-side))
(tr (if main? "workspace.branches.review.missing.open-main"
"workspace.branches.review.missing.open-branch")))])]
[:div {:class (stl/css :preview-missing)}
[:> i/icon* {:icon-id icon :size "s" :class (stl/css :preview-missing-icon)}]
[:> text* {:as "span" :typography t/body-small :class (stl/css :preview-missing-title)} title]
(when hint
[:> text* {:as "span" :typography t/body-small :class (stl/css :preview-missing-hint)} hint])]))
(mf/defc entity-preview*
"Visual preview of one side of a change, dispatching on entity kind:
shapes render to SVG, library colors to a swatch, typographies to a text
sample; everything else shows a type icon. A side with nothing to draw
says why (`missing`: `{:reason :side :open-side}`, see
`missing-reason`)."
[{:keys [kind value objects missing]}]
(cond
(nil? value)
[:> missing-preview* {:reason (:reason missing :unavailable)
:side (:side missing)
:open-side (:open-side missing)}]
(= kind :shape)
(if (contains? renderable-shape-types (:type value))
[:> shape-preview* {:shape value :objects objects}]
[:div {:class (stl/css :preview-empty)}
[:> i/icon* {:icon-id (brc/shape-icon-id {:shape-type (:type value)})}]])
(= kind :color)
[:div {:class (stl/css :preview-swatch)
:style {:background (or (uc/color->background value)
(brc/hex-color value)
"transparent")}}]
(= kind :typography)
[:div {:class (stl/css :preview-type-sample)
:style {:font-family (:font-family value)
:font-weight (:font-weight value)
:font-style (:font-style value)}}
"Ag"]
:else
[:div {:class (stl/css :preview-empty)}
[:> i/icon* {:icon-id (get brc/kind->icon kind i/git-branch)}]]))

View File

@ -0,0 +1,65 @@
// 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/_borders.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/typography.scss" as *;
.preview-svg {
inline-size: 100%;
block-size: 100%;
}
.preview-swatch {
inline-size: 100%;
block-size: 100%;
border-radius: $br-4;
}
.preview-type-sample {
@include use-typography("headline-medium");
color: var(--color-static-black);
line-height: 1;
}
.preview-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-static-black);
}
// the empty stage sits on the light checkerboard of `preview-card*`, the
// same in both themes, so its text is dark whatever the theme
.preview-missing {
--preview-missing-color: var(--color-static-black);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-xxs);
inline-size: 100%;
block-size: 100%;
padding: var(--sp-s);
border: $b-1 dashed color-mix(in srgb, var(--preview-missing-color) 30%, transparent);
border-radius: $br-4;
text-align: center;
}
.preview-missing-icon {
margin-block-end: var(--sp-xxs);
color: color-mix(in srgb, var(--preview-missing-color) 60%, transparent);
}
.preview-missing-title {
color: var(--preview-missing-color);
}
.preview-missing-hint {
color: color-mix(in srgb, var(--preview-missing-color) 60%, transparent);
}

File diff suppressed because it is too large Load Diff

View File

@ -169,6 +169,7 @@
color: var(--color-foreground-primary);
}
.field {
display: flex;
flex-direction: column;
@ -342,53 +343,8 @@
gap: var(--sp-s);
}
// --- Compare changes dialog
// --- Conflict badge (branch info dialog)
// The panes keep a fixed height whatever they list, as the dialog did
// before `modal*`; each pane scrolls on its own.
.compare-content {
--compare-panes-block-size: min(60vh, px2rem(520));
display: flex;
flex-direction: column;
}
.compare-breadcrumb {
display: flex;
align-items: center;
gap: var(--sp-xs);
@include use-typography("body-small");
color: var(--color-foreground-secondary);
}
.breadcrumb-swap {
--breadcrumb-swap-foreground-color: var(--color-foreground-secondary);
display: flex;
justify-content: center;
align-items: center;
padding: var(--sp-xxs);
border: none;
border-radius: $br-4;
background: none;
color: var(--breadcrumb-swap-foreground-color);
cursor: pointer;
&:hover {
--breadcrumb-swap-foreground-color: var(--color-accent-primary);
}
}
.compare-stats {
display: flex;
align-items: center;
gap: var(--sp-xs);
margin-inline-start: auto;
}
.stat-badge,
.item-badge {
@include use-typography("body-small");
@ -397,717 +353,11 @@
white-space: nowrap;
}
.badge-added {
color: var(--color-accent-success);
background-color: var(--color-background-success);
}
.badge-modified {
color: var(--color-accent-info);
background-color: var(--color-background-info);
}
.badge-deleted {
color: var(--color-foreground-error);
background-color: var(--color-background-error);
}
.badge-conflict {
color: var(--color-accent-warning);
background-color: var(--color-background-warning);
}
.compare-empty {
display: flex;
align-items: center;
justify-content: center;
block-size: var(--compare-panes-block-size);
}
.compare-body {
display: grid;
grid-template-columns: minmax(px2rem(320), 1fr) 2fr;
block-size: var(--compare-panes-block-size);
}
.compare-side {
display: flex;
flex-direction: column;
min-block-size: 0;
}
.compare-filters {
display: flex;
flex-wrap: wrap;
gap: var(--sp-xs);
padding: var(--sp-s);
}
.filter-chip {
@include use-typography("body-small");
display: inline-flex;
align-items: center;
gap: var(--sp-xs);
padding: var(--sp-xxs) var(--sp-s);
border-radius: $br-12;
border: $b-1 solid transparent;
background: none;
cursor: pointer;
color: var(--color-foreground-secondary);
&:hover {
background-color: var(--color-background-tertiary);
}
&.is-active {
border-color: var(--color-background-quaternary);
background-color: var(--color-background-tertiary);
color: var(--color-foreground-primary);
}
}
.filter-dot {
inline-size: px2rem(8);
block-size: px2rem(8);
border-radius: $br-circle;
background-color: var(--color-foreground-secondary);
.dot-added & {
background-color: var(--color-accent-success);
}
.dot-modified & {
background-color: var(--color-accent-info);
}
.dot-deleted & {
background-color: var(--color-foreground-error);
}
}
.compare-list {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--sp-m);
min-block-size: 0;
padding: 0 var(--sp-s) var(--sp-s);
overflow-y: auto;
}
.compare-group {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.compare-group-head {
@include use-typography("body-small");
display: flex;
align-items: center;
gap: var(--sp-xs);
padding: var(--sp-xs);
color: var(--color-foreground-secondary);
}
.compare-group-label {
flex: 1;
}
.compare-group-counts {
@include use-typography("code-font");
display: inline-flex;
gap: var(--sp-xs);
}
.count-added {
color: var(--color-accent-success);
}
.count-modified {
color: var(--color-accent-info);
}
.count-deleted {
color: var(--color-foreground-error);
}
.compare-group-items {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
margin: 0;
padding: 0;
list-style: none;
}
.compare-item {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s);
border-radius: $br-8;
border-inline-start: $b-2 solid transparent;
background-color: var(--color-background-tertiary);
cursor: pointer;
&:hover,
&.is-selected {
background-color: var(--color-background-quaternary);
}
&.is-excluded {
opacity: 0.55;
}
&.status-added {
border-inline-start-color: var(--color-accent-success);
}
&.status-modified {
border-inline-start-color: var(--color-accent-info);
}
&.status-deleted {
border-inline-start-color: var(--color-foreground-error);
}
&.status-conflict {
border-inline-start-color: var(--color-accent-warning);
}
}
.compare-item-check {
display: flex;
align-items: center;
}
.compare-item-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-foreground-secondary);
}
.compare-item-body {
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
min-inline-size: 0;
}
.compare-item-label {
@include use-typography("body-medium");
color: var(--color-foreground-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compare-item-subtitle {
@include use-typography("body-small");
color: var(--color-foreground-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compare-detail {
min-block-size: 0;
padding: var(--sp-l);
overflow-y: auto;
border-inline-start: $b-1 solid var(--color-background-quaternary);
}
.compare-detail-empty {
display: flex;
block-size: 100%;
align-items: center;
justify-content: center;
}
.compare-detail-content {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.compare-detail-head {
display: flex;
align-items: baseline;
gap: var(--sp-s);
}
.compare-detail-title {
@include use-typography("body-medium");
margin: 0;
color: var(--color-foreground-primary);
}
.compare-detail-type {
@include use-typography("headline-small");
color: var(--color-foreground-secondary);
}
.compare-detail-hint {
@include use-typography("body-medium");
color: var(--color-foreground-secondary);
}
.prop-changes {
display: flex;
flex-direction: column;
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-12;
overflow: hidden;
}
.prop-changes-head {
@include use-typography("body-medium");
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-s) var(--sp-m);
border-block-end: $b-1 solid var(--color-background-quaternary);
color: var(--color-foreground-primary);
}
.prop-changes-count {
@include use-typography("code-font");
color: var(--color-foreground-secondary);
}
.prop-row {
display: grid;
grid-template-columns: minmax($sz-120, 1fr) 2fr;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s) var(--sp-m);
border-block-end: $b-1 solid var(--color-background-quaternary);
&:last-child {
border-block-end: none;
}
}
.prop-name {
@include use-typography("body-medium");
color: var(--color-foreground-secondary);
}
.prop-values {
display: flex;
align-items: center;
gap: var(--sp-s);
color: var(--color-foreground-secondary);
}
.value-chip {
@include use-typography("code-font");
display: inline-flex;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-xxs) var(--sp-xs);
border-radius: var(--sp-xs);
}
.value-main {
color: var(--color-foreground-error);
background-color: var(--color-background-error);
}
.value-branch {
color: var(--color-accent-success);
background-color: var(--color-background-success);
}
.value-base {
color: var(--color-foreground-secondary);
background-color: var(--color-background-tertiary);
}
.value-swatch {
inline-size: $sz-16;
block-size: $sz-16;
border-radius: var(--sp-xs);
border: $b-1 solid var(--color-background-quaternary);
}
.compare-footer-info {
@include use-typography("body-small");
display: flex;
gap: var(--sp-xs);
color: var(--color-foreground-secondary);
}
.compare-footer-conflicts {
color: var(--color-accent-warning);
}
.compare-footer-selected {
color: var(--color-foreground-secondary);
}
// --- Resolve conflicts dialog
.conflicts-progress {
@include use-typography("code-font");
margin-inline-start: auto;
padding: var(--sp-xxs) var(--sp-s);
border-radius: $br-12;
color: var(--color-accent-info);
background-color: var(--color-background-info);
}
.conflicts-toolbar {
display: flex;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s) 0;
border-block-end: $b-1 solid var(--color-background-quaternary);
}
.bulk-label {
@include use-typography("body-small");
color: var(--color-foreground-secondary);
}
.conflicts-pending {
@include use-typography("code-font");
margin-inline-start: auto;
color: var(--color-accent-warning);
}
// left list
.conflicts-list-side {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
min-block-size: 0;
padding-block-start: var(--sp-s);
}
.conflicts-list-head {
@include use-typography("headline-small");
padding-inline: var(--sp-s);
color: var(--color-foreground-secondary);
}
.conflict-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s);
border-radius: $br-8;
border: $b-1 solid transparent;
background-color: var(--color-background-tertiary);
cursor: pointer;
&:hover {
background-color: var(--color-background-quaternary);
}
&.is-selected {
border-color: var(--color-background-quaternary);
background-color: var(--color-background-quaternary);
}
// a selected-but-unresolved conflict gets the warning outline
&.is-selected.is-pending {
border-color: var(--color-accent-warning);
}
}
.conflict-item-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-foreground-secondary);
}
.conflict-item-body {
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
min-inline-size: 0;
}
.conflict-item-label {
@include use-typography("body-medium");
color: var(--color-foreground-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conflict-item-subtitle {
@include use-typography("body-small");
color: var(--color-foreground-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conflict-pill {
@include use-typography("body-small");
display: inline-flex;
align-items: center;
gap: var(--sp-xs);
padding: var(--sp-xxs) var(--sp-s);
border-radius: $br-12;
}
.pill-dot {
inline-size: $sz-6;
block-size: $sz-6;
border-radius: $br-circle;
background-color: currentcolor;
}
.pill-main {
color: var(--color-foreground-secondary);
background-color: var(--color-background-tertiary);
}
.pill-branch {
color: var(--color-accent-primary);
background-color: var(--color-background-tertiary);
}
.pill-mixed {
color: var(--color-accent-info);
background-color: var(--color-background-info);
}
.pill-pending {
color: var(--color-accent-warning);
background-color: var(--color-background-warning);
}
// right detail
.conflict-detail-head {
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
margin-block-end: var(--sp-l);
}
.conflict-detail-titlerow {
display: flex;
align-items: center;
gap: var(--sp-s);
}
.conflict-detail-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-accent-warning);
}
.conflict-detail-title {
@include use-typography("headline-small");
margin: 0;
color: var(--color-foreground-primary);
}
.conflict-detail-subtitle {
@include use-typography("body-small");
color: var(--color-foreground-secondary);
}
// header: global per-element actions sit at the end of the title row
.conflict-global-actions {
display: flex;
align-items: center;
gap: var(--sp-xs);
margin-inline-start: auto;
}
// --- preview strip: BASE · MAIN · BRANCH · RESULT
.preview-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-s);
align-items: start;
margin: var(--sp-l) 0;
}
.preview-col {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-xs);
min-inline-size: 0;
&.is-result .preview-box {
border-color: var(--color-accent-primary);
}
}
.preview-col-label {
@include use-typography("headline-small");
color: var(--color-foreground-secondary);
.is-result & {
color: var(--color-accent-primary);
}
}
.preview-col-sub {
@include use-typography("body-small");
color: var(--color-foreground-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-inline-size: 100%;
}
.preview-box {
display: flex;
align-items: center;
justify-content: center;
inline-size: 100%;
aspect-ratio: 1;
padding: var(--sp-s);
border-radius: $br-8;
border: $b-2 solid var(--color-background-quaternary);
background-color: var(--color-background-primary);
// checkerboard so transparency / light fills read clearly
background-image:
linear-gradient(45deg, var(--color-background-tertiary) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-background-tertiary) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-background-tertiary) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-background-tertiary) 75%);
background-size: px2rem(12) px2rem(12);
background-position:
0 0,
0 px2rem(6),
px2rem(6) px2rem(-6),
px2rem(-6) 0;
overflow: hidden;
}
.preview-svg {
max-inline-size: 100%;
max-block-size: 100%;
}
.preview-swatch {
inline-size: 100%;
block-size: 100%;
border-radius: $br-4;
}
.preview-type-sample {
@include use-typography("headline-medium");
color: var(--color-foreground-primary);
line-height: 1;
}
.preview-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-foreground-secondary);
}
// --- per-property table
.prop-table {
display: flex;
flex-direction: column;
border-radius: $br-8;
border: $b-1 solid var(--color-background-quaternary);
overflow: hidden;
}
.prop-table-head,
.prop-pick-row {
display: grid;
grid-template-columns: minmax($sz-120, 1fr) 1fr 1fr;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s);
}
.prop-table-head {
background-color: var(--color-background-tertiary);
}
.prop-table-th {
@include use-typography("headline-small");
color: var(--color-foreground-secondary);
}
.prop-pick-row {
border-block-start: $b-1 solid var(--color-background-quaternary);
}
.prop-pick-name {
@include use-typography("body-small");
color: var(--color-foreground-primary);
}
.prop-pick {
display: flex;
align-items: center;
justify-content: flex-start;
padding: var(--sp-xs);
border-radius: $br-8;
border: $b-2 solid transparent;
background: none;
cursor: pointer;
&:hover {
border-color: var(--color-background-quaternary);
}
&.is-chosen {
border-color: var(--color-accent-primary);
background-color: var(--color-background-tertiary);
}
}
.conflict-whole-hint {
@include use-typography("body-medium");
color: var(--color-foreground-secondary);
}
// --- Branch context banner
.branch-banner {

View File

@ -608,7 +608,7 @@
[:div {:class (stl/css :pr-banner-actions)}
[:> button* {:variant "secondary"
:icon i/switch
:icon i/git-compare-arrows
:on-click on-compare}
(tr "workspace.branches.compare")]

View File

@ -110,6 +110,7 @@
[frontend-tests.tokens.style-dictionary-test]
[frontend-tests.tokens.token-errors-test]
[frontend-tests.tokens.workspace-tokens-remap-test]
[frontend-tests.ui.branch-review-common-test]
[frontend-tests.ui.branches-compare-labels-test]
[frontend-tests.ui.check-updates-test]
[frontend-tests.ui.color-token-position-test]
@ -268,6 +269,7 @@
'frontend-tests.tokens.token-errors-test
'frontend-tests.tokens.logic.tokens-status-test
'frontend-tests.tokens.workspace-tokens-remap-test
'frontend-tests.ui.branch-review-common-test
'frontend-tests.ui.branches-compare-labels-test
'frontend-tests.ui.check-updates-test
'frontend-tests.ui.color-token-position-test

View File

@ -0,0 +1,144 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.ui.branch-review-common-test
"How the decisions dialog splits a conflict: the properties both sides
moved clash, the ones a single side moved come filled in from it."
(:require
[app.common.files.branch-merge :as bm]
[app.common.uuid :as uuid]
[app.main.ui.workspace.sidebar.branch-review.common :as brc]
[cljs.test :as t :include-macros true]))
(def ^:private card
"Both sides edited the card: the radius on both, the width on the
branch alone and the opacity on main alone."
{:kind :shape
:status :conflict
:reason :modify-modify
:base {:rx 8 :width 320 :opacity 1}
:main {:rx 12 :width 320 :opacity 0.5}
:branch {:rx 4 :width 360 :opacity 1}
:changed-attrs {:rx {:main 12 :branch 4}
:width {:main 320 :branch 360}
:opacity {:main 0.5 :branch 1}}})
(defn- sources
[conflict]
(into {} (map (juxt :attr :source)) (brc/conflict-attr-rows conflict)))
(t/deftest only-properties-both-sides-moved-clash
(t/is (= {:rx :clash :width :branch :opacity :main} (sources card))))
(t/deftest without-a-base-every-property-clashes
(t/is (= #{:clash} (set (vals (sources (assoc card :base nil :reason :add-add))))))
(t/is (= #{:clash} (set (vals (sources (dissoc card :base)))))))
(t/deftest independent-edits-come-filled-in
(let [res (brc/prefill-resolution card)]
(t/is (= {:width :branch :opacity :main} res))
(t/is (not (bm/conflict-resolved? card res))
"the clashing radius still waits for a choice")))
(t/deftest choosing-the-clash-resolves-the-conflict
(let [res (brc/choose-attr card nil :rx :branch)]
(t/is (= {:rx :branch :width :branch :opacity :main} res))
(t/is (bm/conflict-resolved? card res))
(t/is (= [{:attr :rx :side :main :value 12}]
(brc/dropped-values card res))
"keeping the branch radius drops main's, and nothing else")))
(t/deftest a-conflict-without-a-clash-comes-out-resolved
(let [conflict (-> card
(assoc-in [:main :rx] 8)
(update :changed-attrs dissoc :rx))]
(t/is (bm/conflict-resolved? conflict (brc/prefill-resolution conflict)))))
(t/deftest bulk-choice-keeps-independent-edits
(t/is (= {:rx :main :width :branch :opacity :main} (brc/choose-side card :main)))
(t/is (= :branch (brc/choose-side (assoc card :changed-attrs {} :main nil) :branch))
"an entity that cannot be split takes the whole side"))
(t/deftest paragraphs-say-which-edits-a-side-keeps-and-drops
(let [text (fn [& ps]
{:type "root"
:children [{:type "paragraph-set"
:children (mapv (fn [p] {:type "paragraph"
:children [{:text p}]})
ps)}]})
note {:kind :shape
:status :conflict
:base {:content (text "One" "Two" "Three")}
:main {:content (text "One step" "Two" "Three")}
:branch {:content (text "One" "Two" "Three more")}
:changed-attrs {:content {}}}]
(t/is (= [:kept nil :dropped] (mapv :status (brc/paragraph-rows note :main))))
(t/is (= [:dropped nil :kept] (mapv :status (brc/paragraph-rows note :branch))))))
;; --- What a new or deleted entry is, where it lands, what it affects
(def ^:private board-id #uuid "00000000-0000-0000-0000-000000000001")
(def ^:private group-id #uuid "00000000-0000-0000-0000-000000000002")
(def ^:private rect-id #uuid "00000000-0000-0000-0000-000000000003")
(def ^:private copy-id #uuid "00000000-0000-0000-0000-000000000004")
(def ^:private comp-id #uuid "00000000-0000-0000-0000-000000000005")
(def ^:private color-id #uuid "00000000-0000-0000-0000-000000000006")
(def ^:private page-id #uuid "00000000-0000-0000-0000-000000000007")
(def ^:private objects
{uuid/zero {:id uuid/zero :type :frame :shapes [board-id]}
board-id {:id board-id :type :frame :name "Checkout" :parent-id uuid/zero
:frame-id uuid/zero :shapes [group-id copy-id]}
group-id {:id group-id :type :group :name "Summary" :parent-id board-id
:frame-id board-id :shapes [rect-id]}
rect-id {:id rect-id :type :rect :name "Price" :parent-id group-id
:frame-id board-id
:fills [{:fill-color "#000000" :fill-color-ref-id color-id}]
:applied-tokens {:fill "brand.primary"}}
copy-id {:id copy-id :type :frame :name "Button" :parent-id board-id
:frame-id board-id :component-id comp-id}})
(def ^:private data
{:pages ["p"]
:pages-index {page-id {:id page-id :name "Page 1" :objects objects}}})
(t/deftest a-page-counts-its-boards-and-layers
(let [{:keys [boards layers]} (brc/page-contents {:objects objects})]
(t/is (= ["Checkout"] (map :name boards)))
(t/is (= 4 layers) "the root frame does not count")))
(t/deftest a-layer-names-the-containers-around-it
(t/is (= ["Checkout" "Summary"] (brc/shape-path objects rect-id)))
(t/is (= [] (brc/shape-path objects board-id))))
(t/deftest library-entries-count-the-layers-that-use-them
(t/is (= 1 (brc/component-copies data comp-id)))
(t/is (= 1 (brc/color-usages data color-id)))
(t/is (= 1 (brc/token-usages data "brand.primary")))
(t/is (= 0 (brc/token-usages data "brand.secondary"))))
(t/deftest pages-get-their-own-group-before-the-library
(let [groups (brc/group-by-place [[0 {:kind :page :status :added :label "Page 2"}]
[1 {:kind :color :status :added :label "Red"}]]
{} [])]
(t/is (= ["pages" "library"] (map :id groups)))))
(t/deftest a-board-folds-only-two-or-more-changes
(let [pages-index {page-id {:id page-id :name "Page 1" :objects objects}}
rect [0 {:kind :shape :status :modified :id rect-id :page-id page-id}]
button [1 {:kind :shape :status :modified :id copy-id :page-id page-id}]
one (first (brc/group-by-place [rect] pages-index []))
two (first (brc/group-by-place [rect button] pages-index []))]
(t/is (= [] (:boards one)) "a lone change stays loose")
(t/is (= [rect] (:rows one)))
(t/is (= ["Checkout"] (map :name (:boards two))))
(t/is (= [] (:rows two)))))
(t/deftest only-decisions-carry-a-colour
(t/is (= :variants (brc/category {:kind :shape :status :modified :variant? true :component? true})))
(t/is (= :components (brc/category {:kind :shape :status :modified :component? true})))
(t/is (= #{"neutral"} (set (vals (dissoc brc/category->tone :decisions)))))
(t/is (= "warning" (brc/category->tone :decisions))))

View File

@ -10,13 +10,13 @@
them. The catalogue here is a stand-in for en.po: its values are picked
to differ from the English the dialog used to hardcode."
(:require
[app.main.ui.workspace.sidebar.branches :as branches]
[app.main.ui.workspace.sidebar.branch-review.common :as brc]
[app.util.i18n :as i18n]
[cljs.test :as t :include-macros true]))
;; Access the private formatters for direct testing.
(def ^:private attr-label @#'branches/attr-label)
(def ^:private display-val @#'branches/display-val)
;; The formatters under test.
(def ^:private attr-label brc/attr-label)
(def ^:private display-val brc/display-val)
(def ^:private get-translations @#'i18n/get-translations)
(def ^:private catalogue

View File

@ -10928,3 +10928,641 @@ msgstr ""
#: src/app/main/ui/workspace/sidebar/versions.cljs:462
msgid "workspace.versions.warning.text"
msgstr "Autosaved versions will be kept for %s days."
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:540
msgid "workspace.branches.resolve.back"
msgstr "Back to review"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:361
msgid "workspace.branches.resolve.change-choice"
msgstr "Change choice"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:150
msgid "workspace.branches.resolve.choose-side"
msgstr "Choose a side"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:241
msgid "workspace.branches.resolve.copy-dropped"
msgstr "Copy dropped text"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:99
msgid "workspace.branches.resolve.decided"
msgstr "Decided"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:259
msgid "workspace.branches.resolve.deletes-it"
msgstr "This version deletes it"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:358
msgid "workspace.branches.resolve.drops.none"
msgstr "Nothing is dropped: every edit is kept."
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:232
msgid "workspace.branches.resolve.drops-paragraphs"
msgstr "Drops the other side's %s"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:349
msgid "workspace.branches.resolve.drops.title"
msgstr "What this choice drops"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:305
msgid "workspace.branches.resolve.intro-attrs"
msgstr "Main and this branch both edited this. Only the properties marked Clash need a choice; the others changed on one side only, so they are already filled in."
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:304
msgid "workspace.branches.resolve.intro-text"
msgstr "Main and this branch both edited this text. Penpot keeps one whole text, so if each side changed a different paragraph, either choice drops one of those edits."
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:306
msgid "workspace.branches.resolve.intro-whole"
msgstr "This cannot be split by property. Keep one of the two versions."
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:503
msgid "workspace.branches.resolve.keep-all-branch"
msgstr "Keep branch for all"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:501
msgid "workspace.branches.resolve.keep-all-main"
msgstr "Keep main for all"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:328
msgid "workspace.branches.resolve.keep-branch"
msgstr "Keep branch - %s"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:227
msgid "workspace.branches.resolve.keep-branch-text"
msgstr "Keep your text"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:254
msgid "workspace.branches.resolve.keep-branch-version"
msgstr "Keep your version"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:325
msgid "workspace.branches.resolve.keep-main"
msgstr "Keep main - %s"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:226
msgid "workspace.branches.resolve.keep-main-text"
msgstr "Keep main's text"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:253
msgid "workspace.branches.resolve.keep-main-version"
msgstr "Keep main's version"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:239
msgid "workspace.branches.resolve.keep-safe"
msgstr "Keep the dropped edit safe:"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:545
msgid "workspace.branches.resolve.next"
msgstr "Next decision"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:79
msgid "workspace.branches.resolve.row.both-changed"
msgstr "%s - both changed"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:74
msgid "workspace.branches.resolve.row.drops"
msgstr "Drops the other side's %s"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:76
msgid "workspace.branches.resolve.row.kept-both"
msgstr "Both sides' edits kept"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:73
msgid "workspace.branches.resolve.row.kept-branch"
msgstr "Kept your version"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:72
msgid "workspace.branches.resolve.row.kept-main"
msgstr "Kept main's version"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:102
msgid "workspace.branches.resolve.row.loses-work"
msgstr "Loses work"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:156
msgid "workspace.branches.resolve.tag.clash"
msgstr "Clash"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:160
msgid "workspace.branches.resolve.tag.from-branch"
msgstr "From branch"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:158
msgid "workspace.branches.resolve.tag.from-main"
msgstr "From main"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:154
msgid "workspace.branches.resolve.tag.your-choice"
msgstr "Your choice"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:118
msgid "workspace.branches.resolve.th.branch"
msgstr "Branch"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:117
msgid "workspace.branches.resolve.th.main"
msgstr "Main"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:116
msgid "workspace.branches.resolve.th.property"
msgstr "Property"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:119
msgid "workspace.branches.resolve.th.result"
msgstr "Result"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:476
msgid "workspace.branches.resolve.title"
msgstr "Resolve decisions"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:135
msgid "workspace.branches.resolve.unchanged"
msgstr "unchanged"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:772
msgid "workspace.branches.review.action.mark-next"
msgstr "Mark reviewed - next"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:768
msgid "workspace.branches.review.action.previous"
msgstr "Previous"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:141
msgid "workspace.branches.review.attr.fill"
msgstr "Fill"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:143
msgid "workspace.branches.review.attr.gap"
msgstr "Gap between items"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:144
msgid "workspace.branches.review.attr.padding"
msgstr "Padding"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:142
msgid "workspace.branches.review.attr.stroke"
msgstr "Stroke"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:140
msgid "workspace.branches.review.attr.text"
msgstr "Text"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:258
msgid "workspace.branches.review.category.colour"
msgstr "Color"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:260
msgid "workspace.branches.review.category.components"
msgstr "Components"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:257
msgid "workspace.branches.review.category.copy"
msgstr "Copy"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:256
msgid "workspace.branches.review.category.decisions"
msgstr "Decisions"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:259
msgid "workspace.branches.review.category.layout"
msgstr "Layout"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:262
msgid "workspace.branches.review.category.other"
msgstr "Other"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:261
msgid "workspace.branches.review.category.tokens"
msgstr "Tokens"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:342
msgid "workspace.branches.review.decision.body"
msgstr "Main and this branch both edited it. Choose which version to keep before merging."
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:750
msgid "workspace.branches.review.footer.reviewed"
msgstr "%s of %s reviewed"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:151
msgid "workspace.branches.review.group.decisions"
msgstr "Needs your decision (%s)"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:392
msgid "workspace.branches.review.group.library"
msgstr "Library"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:279
msgid "workspace.branches.review.overlay-detail"
msgstr "Branch over main"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:72
msgid "workspace.branches.review.row.choose"
msgstr "Choose"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:75
msgid "workspace.branches.review.row.left-out"
msgstr "Left out"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:78
msgid "workspace.branches.review.row.reviewed"
msgstr "Reviewed"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:228
msgid "workspace.branches.review.side.branch"
msgstr "Branch"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:221
msgid "workspace.branches.review.side.main"
msgstr "Main"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:337
msgid "workspace.branches.review.side.result"
msgstr "Result"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:321
msgid "workspace.branches.review.summary.changed"
msgstr "Changed"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:318
msgid "workspace.branches.review.summary.deleted"
msgstr "Deleted %s"
#: src/app/main/ui/workspace/sidebar/branch_review/layout.cljs:72
msgid "workspace.branches.review.this-branch"
msgstr "Changes:"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:643
msgid "workspace.branches.review.title"
msgstr "Review changes"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs:381
msgid "workspace.branches.review.unknown-page"
msgstr "Unknown page"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:202
msgid "workspace.branches.review.view.overlay"
msgstr "Overlay"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:201
msgid "workspace.branches.review.view.side"
msgstr "Side by side"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:535
msgid "workspace.branches.resolve.footer.blocked"
msgid_plural "workspace.branches.resolve.footer.blocked"
msgstr[0] "Merge blocked: 1 decision left"
msgstr[1] "Merge blocked: %s decisions left"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:297
msgid "workspace.branches.resolve.tag.clashes"
msgid_plural "workspace.branches.resolve.tag.clashes"
msgstr[0] "1 clash"
msgstr[1] "%s clashes"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs:299
msgid "workspace.branches.resolve.tag.independent"
msgid_plural "workspace.branches.resolve.tag.independent"
msgstr[0] "1 independent edit"
msgstr[1] "%s independent edits"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:631
msgid "workspace.branches.review.action.merge"
msgid_plural "workspace.branches.review.action.merge"
msgstr[0] "Merge 1 change"
msgstr[1] "Merge %s changes"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:618
msgid "workspace.branches.review.action.resolve"
msgid_plural "workspace.branches.review.action.resolve"
msgstr[0] "Resolve 1 decision"
msgstr[1] "Resolve %s decisions"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:625
msgid "workspace.branches.review.action.update"
msgid_plural "workspace.branches.review.action.update"
msgstr[0] "Update with 1 change"
msgstr[1] "Update with %s changes"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:130
msgid "workspace.branches.review.board.inside"
msgid_plural "workspace.branches.review.board.inside"
msgstr[0] "Board - 1 change inside"
msgstr[1] "Board - %s changes inside"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:753
msgid "workspace.branches.review.footer.blocked"
msgid_plural "workspace.branches.review.footer.blocked"
msgstr[0] "merge blocked by 1 decision"
msgstr[1] "merge blocked by %s decisions"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:755
msgid "workspace.branches.review.footer.left-out"
msgid_plural "workspace.branches.review.footer.left-out"
msgstr[0] "1 left out"
msgstr[1] "%s left out"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs:88
msgid "workspace.branches.review.row.edits"
msgid_plural "workspace.branches.review.row.edits"
msgstr[0] "1 edit"
msgstr[1] "%s edits"
#: src/app/main/ui/workspace/sidebar/branch_review/previews.cljs
msgid "workspace.branches.review.missing.not-in-main"
msgstr "Not in main"
#: src/app/main/ui/workspace/sidebar/branch_review/previews.cljs
msgid "workspace.branches.review.missing.not-in-branch"
msgstr "Not in this branch"
#: src/app/main/ui/workspace/sidebar/branch_review/previews.cljs
msgid "workspace.branches.review.missing.new-in-branch"
msgstr "New in this branch"
#: src/app/main/ui/workspace/sidebar/branch_review/previews.cljs
msgid "workspace.branches.review.missing.new-in-main"
msgstr "New in main"
#: src/app/main/ui/workspace/sidebar/branch_review/previews.cljs
msgid "workspace.branches.review.missing.deleted-in-main"
msgstr "Deleted in main"
#: src/app/main/ui/workspace/sidebar/branch_review/previews.cljs
msgid "workspace.branches.review.missing.deleted-in-branch"
msgstr "Deleted in this branch"
#: src/app/main/ui/workspace/sidebar/branch_review/previews.cljs
msgid "workspace.branches.review.missing.no-preview"
msgstr "No preview"
#: src/app/main/ui/workspace/sidebar/branch_review/previews.cljs
msgid "workspace.branches.review.missing.open-main"
msgstr "Open main to see it"
#: src/app/main/ui/workspace/sidebar/branch_review/previews.cljs
msgid "workspace.branches.review.missing.open-branch"
msgstr "Open the branch to see it"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.text"
msgstr "Text rewritten"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.renamed"
msgstr "Renamed"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.moved"
msgstr "Moved"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.resized"
msgstr "Resized"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.rotated"
msgstr "Rotated"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.reparented"
msgstr "Moved to another container"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.visibility"
msgstr "Shown or hidden"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.spacing"
msgstr "Spacing changed"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.layout"
msgstr "Layout changed"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.attr"
msgstr "%s changed"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.summary.more"
msgid_plural "workspace.branches.review.summary.more"
msgstr[0] "+1 more"
msgstr[1] "+%s more"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.page"
msgstr "Page"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.page-settings"
msgstr "Page settings"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.page-order"
msgstr "Page order"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.guides"
msgstr "Guides"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.flows"
msgstr "Flows"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.grids"
msgstr "Grids"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.plugin-data"
msgstr "Plugin data"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.file-options"
msgstr "File options"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.token-set"
msgstr "Token set"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.token-set-order"
msgstr "Token set order"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.theme"
msgstr "Theme"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.active-themes"
msgstr "Active themes"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.type.active-sets"
msgstr "Active token sets"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.contents"
msgstr "Contents"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.board-names"
msgstr "Boards"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.page-goes"
msgstr "This page goes away"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.page-goes-merge"
msgstr "Merging deletes this page and everything on it: %s and %s."
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.page-goes-update"
msgstr "Updating deletes this page and everything on it: %s and %s."
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.changes-on-page"
msgstr "Changes on this page (%s)"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.type"
msgstr "Type"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.size"
msgstr "Size"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.position"
msgstr "Position"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.where-was"
msgstr "Where it was"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.where-lands"
msgstr "Where it lands"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.copies-orphaned-title"
msgstr "Copies lose their main component"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.unused-title"
msgstr "Not used in this file"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.deleted-unused"
msgstr "No layer in this file uses it, so deleting it breaks nothing."
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.used-by"
msgstr "Used in this file"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.boards"
msgid_plural "workspace.branches.review.facts.boards"
msgstr[0] "1 board"
msgstr[1] "%s boards"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.layers"
msgid_plural "workspace.branches.review.facts.layers"
msgstr[0] "1 layer"
msgstr[1] "%s layers"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.copies"
msgid_plural "workspace.branches.review.facts.copies"
msgstr[0] "1 copy"
msgstr[1] "%s copies"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.copies-orphaned-merge"
msgid_plural "workspace.branches.review.facts.copies-orphaned-merge"
msgstr[0] "1 copy in this file points at a component the merge deletes."
msgstr[1] "%s copies in this file point at a component the merge deletes."
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.copies-orphaned-update"
msgid_plural "workspace.branches.review.facts.copies-orphaned-update"
msgstr[0] "1 copy in this file points at a component the update deletes."
msgstr[1] "%s copies in this file point at a component the update deletes."
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.used-title"
msgid_plural "workspace.branches.review.facts.used-title"
msgstr[0] "Used by 1 layer"
msgstr[1] "Used by %s layers"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.deleted-component-used"
msgid_plural "workspace.branches.review.facts.deleted-component-used"
msgstr[0] "1 copy in this file loses its main component."
msgstr[1] "%s copies in this file lose their main component."
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.deleted-used"
msgid_plural "workspace.branches.review.facts.deleted-used"
msgstr[0] "1 layer in this file loses its link to it."
msgstr[1] "%s layers in this file lose their link to it."
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.group.pages"
msgstr "Pages"
#: src/app/main/ui/workspace/sidebar/branch_review/facts.cljs
msgid "workspace.branches.review.facts.empty-page"
msgstr "Empty page"
#: src/app/main/ui/workspace/sidebar/branch_review/common.cljs
msgid "workspace.branches.review.category.variants"
msgstr "Variants"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs
msgid "workspace.branches.resolve.drops.main-short"
msgstr "Main's %s:"
#: src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs
msgid "workspace.branches.resolve.drops.branch-short"
msgstr "The branch's %s:"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs
msgid "workspace.branches.review.view.expand"
msgstr "Expand preview"
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs
msgid "workspace.branches.review.view.collapse"
msgstr "Collapse preview"