mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
✨ 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:
parent
f56da68b95
commit
c68daed816
@ -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`
|
||||
|
||||
@ -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))))
|
||||
@ -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}]))
|
||||
@ -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);
|
||||
}
|
||||
@ -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}]))
|
||||
@ -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);
|
||||
}
|
||||
@ -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))]]}]))))
|
||||
@ -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);
|
||||
}
|
||||
@ -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}]]])])
|
||||
@ -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);
|
||||
}
|
||||
@ -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)}]]))
|
||||
@ -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
@ -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 {
|
||||
|
||||
@ -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")]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
144
frontend/test/frontend_tests/ui/branch_review_common_test.cljs
Normal file
144
frontend/test/frontend_tests/ui/branch_review_common_test.cljs
Normal 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))))
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user