diff --git a/frontend/src/app/main/data/workspace/branches.cljs b/frontend/src/app/main/data/workspace/branches.cljs index be5396dc6f..9a724d4865 100644 --- a/frontend/src/app/main/data/workspace/branches.cljs +++ b/frontend/src/app/main/data/workspace/branches.cljs @@ -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` diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/branch_review/common.cljs new file mode 100644 index 0000000000..7d5d302f5a --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/common.cljs @@ -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)))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/compare.cljs b/frontend/src/app/main/ui/workspace/sidebar/branch_review/compare.cljs new file mode 100644 index 0000000000..6228fc7d23 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/compare.cljs @@ -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}])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/compare.scss b/frontend/src/app/main/ui/workspace/sidebar/branch_review/compare.scss new file mode 100644 index 0000000000..8457bb07fe --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/compare.scss @@ -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); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs b/frontend/src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs new file mode 100644 index 0000000000..74117b54a0 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/conflicts.cljs @@ -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}])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/conflicts.scss b/frontend/src/app/main/ui/workspace/sidebar/branch_review/conflicts.scss new file mode 100644 index 0000000000..20f6fc4f83 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/conflicts.scss @@ -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); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/facts.cljs b/frontend/src/app/main/ui/workspace/sidebar/branch_review/facts.cljs new file mode 100644 index 0000000000..d2f17acb21 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/facts.cljs @@ -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))]]}])))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/facts.scss b/frontend/src/app/main/ui/workspace/sidebar/branch_review/facts.scss new file mode 100644 index 0000000000..bdcc4827bb --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/facts.scss @@ -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); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/layout.cljs b/frontend/src/app/main/ui/workspace/sidebar/branch_review/layout.cljs new file mode 100644 index 0000000000..15a56cab79 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/layout.cljs @@ -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}]]])]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/layout.scss b/frontend/src/app/main/ui/workspace/sidebar/branch_review/layout.scss new file mode 100644 index 0000000000..5da607cfd7 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/layout.scss @@ -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); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/previews.cljs b/frontend/src/app/main/ui/workspace/sidebar/branch_review/previews.cljs new file mode 100644 index 0000000000..7b6eb7e431 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/previews.cljs @@ -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)}]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/branch_review/previews.scss b/frontend/src/app/main/ui/workspace/sidebar/branch_review/previews.scss new file mode 100644 index 0000000000..a90fd6b733 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/branch_review/previews.scss @@ -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); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/branches.cljs b/frontend/src/app/main/ui/workspace/sidebar/branches.cljs index d2d3c0b908..23f239c5bd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/branches.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/branches.cljs @@ -8,14 +8,12 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.common.files.branch-merge :as bm] [app.common.time :as ct] [app.config :as cf] [app.main.data.modal :as modal] [app.main.data.workspace.branches :as dwb] [app.main.data.workspace.pull-requests :as dwpr] [app.main.refs :as refs] - [app.main.render :as render] [app.main.store :as st] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] @@ -27,12 +25,13 @@ [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.avatar :refer [avatar*]] - [app.main.ui.ds.product.branch-pill :refer [branch-pill*]] [app.main.ui.ds.product.empty-state :refer [empty-state*]] [app.main.ui.ds.tooltip :refer [tooltip*]] [app.main.ui.hooks.resize :as r] + [app.main.ui.workspace.sidebar.branch-review.common :as brc] + [app.main.ui.workspace.sidebar.branch-review.compare] + [app.main.ui.workspace.sidebar.branch-review.conflicts] [app.main.ui.workspace.sidebar.pull-requests :as prui :refer [pr-state-badge*]] - [app.util.color :as uc] [app.util.dom :as dom] [app.util.globals :as globals] [app.util.i18n :as i18n :refer [tr]] @@ -45,188 +44,10 @@ (def ^:private branches (l/derived :workspace-branches st/state)) -(def ^:private branch-diff - (l/derived :workspace-branch-diff st/state)) - (def ^:private branch-context ;; see refs/branch-context: the state entry wraps the row under :info (l/derived #(get-in % [:workspace-branch-context :info]) st/state)) -(def ^:private kind->icon - {:shape i/board - :component i/component - :color i/picker - :typography i/text - :media i/img - :page i/document - :page-attrs i/document - :page-order i/document - :page-guide i/document - :page-flow i/document - :page-grid i/document - :page-plugin i/document - :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 status->label - {:added "workspace.branches.status.added" - :modified "workspace.branches.status.modified" - :deleted "workspace.branches.status.deleted" - :conflict "workspace.branches.status.conflict"}) - -(defn- short-str - [v] - (let [s (pr-str v)] - (if (> (count s) 40) - (str (subs s 0 40) "…") - s))) - -(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) (tr "workspace.branches.compare.attr.corner-radius") - :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))) - -;; --- Compare: grouping by component + per-item type labels - -(def ^:private kind->type-label - {:shape "workspace.branches.compare.type.shape" - :component "workspace.branches.compare.type.component" - :color "workspace.branches.compare.type.color" - :typography "workspace.branches.compare.type.typography" - :media "workspace.branches.compare.type.media" - :token "workspace.branches.compare.type.token"}) - -(def ^:private shape-type->icon - {:frame "board" :rect "rectangle" :line "rectangle" :circle "ellipse" - :path "path" :text "text" :image "img" :svg-raw "img" :group "group" - :bool "boolean-union"}) - -(defn- shape-icon-id - "Type-accurate icon for a shape diff entry (component nature wins over - raw type), falling back to the board icon for unknown types." - [{:keys [shape-type component? component-copy? variant? masked?]}] - (cond - variant? "variant" - component? "component" - component-copy? "component-copy" - (and (= :group shape-type) masked?) "mask" - :else (get shape-type->icon shape-type "board"))) - -(def ^:private shape-type->label - {:frame "workspace.branches.compare.shape.board" - :rect "workspace.branches.compare.shape.rect" - :line "workspace.branches.compare.shape.rect" - :circle "workspace.branches.compare.shape.ellipse" - :path "workspace.branches.compare.shape.path" - :text "workspace.branches.compare.shape.text" - :image "workspace.branches.compare.shape.image" - :svg-raw "workspace.branches.compare.shape.image" - :group "workspace.branches.compare.shape.group" - :bool "workspace.branches.compare.shape.bool"}) - -(defn- type-label-key - "i18n key for an item's specific type — type-accurate for shapes - (board/text/rectangle/component/…), generic for other kinds." - [item] - (if (= :shape (:kind item)) - (let [{:keys [component? component-copy? variant? shape-type]} item] - (cond - variant? "workspace.branches.compare.shape.variant" - component? "workspace.branches.compare.shape.component" - component-copy? "workspace.branches.compare.shape.component-copy" - :else (get shape-type->label shape-type - "workspace.branches.compare.type.shape"))) - (get kind->type-label (:kind item)))) - -(defn- item-status - "Conflicts carry `:conflict`; otherwise the regular add/mod/del status." - [item] - (if (= :conflict (:status item)) :conflict (:status item))) - -(defn- hex-color - "Return a usable hex string when `v` looks like a color (a hex string, a - color map, or a fills/strokes vector), else nil." - [v] - (let [s (cond - (string? v) v - (and (map? v) (string? (:color v))) (:color v) - (and (map? v) (string? (:fill-color v))) (:fill-color v) - (and (map? v) (string? (:stroke-color v))) (:stroke-color v) - (and (sequential? v) (map? (first v))) - (or (:fill-color (first v)) (:stroke-color (first v)) (:color (first v))) - :else nil)] - ;; only valid hex lengths (3/4/6/8 digits); a bare string must carry - ;; the leading `#`, otherwise casual words like "cafe" become swatches - (when (and (string? s) - (re-matches #"#[0-9a-fA-F]{8}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3,4}" s)) - s))) - -(defn- fmt-number - "At most two decimals, trailing zeros stripped (89.99999 -> \"90\", - 439.4999 -> \"439.5\", 386 -> \"386\"), 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- display-val - "Compact, human-readable rendering of a property value. Raw uuids and - collections are summarized rather than dumped (they carry no meaning to - the user as raw data)." - [v] - (cond - (nil? v) "—" - (string? v) (if (> (count v) 32) (str (subs v 0 32) "…") v) - (number? v) (fmt-number v) - (boolean? v) (if v - (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) (tr "workspace.branches.compare.n-items" (i18n/c (count v))) - (map? v) (tr "workspace.branches.compare.n-props" (i18n/c (count v))) - :else (short-str v))) - -(defn- status-matches? - [filter status] - (case filter - :all true - :added (= status :added) - :modified (contains? #{:modified :conflict} status) - :deleted (= status :deleted) - true)) - -(def ^:private compare-filters - [[:all "labels.all"] - [:added "workspace.branches.status.added"] - [:modified "workspace.branches.status.modified"] - [:deleted "workspace.branches.status.deleted"]]) - ;; --- Create branch dialog (modal) (mf/defc create-branch-dialog* @@ -441,28 +262,6 @@ ;; --- Confirmation for relevant branch actions (merge / update / materialize) -(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})))})))) - (defn- confirm-materialize! "Ask for confirmation before turning `branch` into an ordinary file: the branch stops being one, so it can no longer be compared or merged." @@ -624,7 +423,7 @@ (tr "workspace.branches.materialize.action")]) ;; the work of a merged branch is in main: there is nothing to compare (when-not merged? - [:> button* {:variant "secondary" :icon i/switch :on-click on-compare} + [:> button* {:variant "secondary" :icon i/git-compare-arrows :on-click on-compare} (tr "workspace.branches.compare")]) [:> button* {:variant "primary" :on-click on-close} (tr "labels.close")]]])) @@ -677,7 +476,7 @@ (fn [event] (dom/stop-propagation event) (on-set-menu false) - (confirm-update! entry))) + (brc/confirm-update! entry))) on-toggle-menu (mf/use-fn (mf/deps on-set-menu menu-open?) @@ -737,7 +536,7 @@ (fn [event] (dom/stop-propagation event) (on-set-menu false) - (confirm-merge! entry))) + (brc/confirm-merge! entry))) on-cancel-review (mf/use-fn @@ -1082,775 +881,6 @@ button]) button))}]])) -;; --- Compare changes dialog (read-only 3-way diff) - -(mf/defc compare-value-chip* - {::mf/private true} - [{:keys [value tone]}] - (let [hex (hex-color value)] - [:span {:class (stl/css-case :value-chip true - :value-main (= tone :main) - :value-branch (= tone :branch) - :value-base (= tone :base))} - (when hex - [:span {:class (stl/css :value-swatch) - :style {:background-color hex}}]) - (if (and hex (not (sequential? value))) - hex - (display-val value))])) - -(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- group-changes - "Group the comparison's rows by the component they belong to (the - nearest main instance up a shape's tree, `:component-id` on the entry); - the rows of no component go last. The per-change selection is offered - per group, mirroring the design-branches PoC's `group-changes` - (`frontend/src/app/main/ui/workspace/branches.cljs@2feb570d3f`)." - [rows] - (->> rows - (group-by (fn [[_ item]] (:component-id item))) - (map (fn [[component-id group-rows]] - (let [item (second (first group-rows)) - row-keys (into #{} (comp (filter (fn [[_ it]] (selectable-item? it))) - (map (fn [[_ it]] (:key it)))) - group-rows)] - {:id (or (some-> component-id str) "other") - :component? (some? component-id) - :name (if component-id - (or (:component-name item) - (tr "workspace.branches.compare.group.component")) - (tr "workspace.branches.compare.group.other")) - :row-keys row-keys - :rows group-rows}))) - (sort-by (juxt (complement :component?) :name)))) - -(mf/defc branch-compare-item* - {::mf/private true} - [{:keys [item index selected on-select excluded? on-toggle]}] - (let [on-click (mf/use-fn (mf/deps index on-select) #(on-select index)) - status (item-status item) - attrs (:changed-attrs item) - type-lbl (type-label-key item) - icon-id (if (= :shape (:kind item)) - (shape-icon-id item) - (get kind->icon (:kind item) i/git-branch)) - selectable? (selectable-item? item) - change-key (:key item) - subtitle (cond - (= :added status) (tr "workspace.branches.compare.subtitle-new" - (if type-lbl (tr type-lbl) "")) - (and (= :modified status) (seq attrs)) - (tr "workspace.branches.compare.subtitle-mods" (i18n/c (count attrs))) - type-lbl (tr type-lbl) - :else nil)] - [:li {:class (stl/css-case :compare-item true - :is-selected (= index selected) - :is-excluded (and selectable? excluded?) - :status-added (= :added status) - :status-modified (= :modified status) - :status-deleted (= :deleted status) - :status-conflict (= :conflict status)) - :role "button" - :on-click on-click} - [:span {:class (stl/css :compare-item-check) - ;; the click must not also select the row for the detail pane - :on-click (fn [event] (when selectable? (.stopPropagation event)))} - (when selectable? - [:> checkbox* {:id change-key - :checked (not excluded?) - :on-change #(on-toggle #{change-key})}])] - [:div {:class (stl/css :compare-item-icon)} - [:> i/icon* {:icon-id icon-id}]] - [:div {:class (stl/css :compare-item-body)} - [:span {:class (stl/css :compare-item-label)} (:label item)] - (when subtitle - [:span {:class (stl/css :compare-item-subtitle)} subtitle])] - [:span {:class (stl/css-case :item-badge true - :badge-added (= :added status) - :badge-modified (= :modified status) - :badge-deleted (= :deleted status) - :badge-conflict (= :conflict status))} - (tr (get status->label status "workspace.branches.status.modified"))]])) - -(mf/defc branch-compare-detail* - {::mf/private true} - [{:keys [item]}] - (let [attrs (:changed-attrs item) - type-lbl (type-label-key item)] - (cond - (nil? item) - [:div {:class (stl/css :compare-detail-empty)} - [:> empty-state* {:icon i/switch - :text (tr "workspace.branches.compare.select-hint")}]] - - :else - [:div {:class (stl/css :compare-detail-content)} - [:div {:class (stl/css :compare-detail-head)} - [:h3 {:class (stl/css :compare-detail-title)} (:label item)] - (when type-lbl - [:span {:class (stl/css :compare-detail-type)} (tr type-lbl)])] - - (if (seq attrs) - [:div {:class (stl/css :prop-changes)} - [:div {:class (stl/css :prop-changes-head)} - [:span (tr "workspace.branches.compare.prop-changes")] - [:span {:class (stl/css :prop-changes-count)} (dm/str (count attrs))]] - (for [[attr {:keys [main branch]}] attrs] - [:div {:class (stl/css :prop-row) :key (str attr)} - [:span {:class (stl/css :prop-name)} (attr-label attr)] - [:div {:class (stl/css :prop-values)} - [:> compare-value-chip* {:value main :tone :main}] - [:> i/icon* {:icon-id i/arrow-long-right :size "s"}] - [:> compare-value-chip* {:value branch :tone :branch}]]])] - [:p {:class (stl/css :compare-detail-hint)} - (tr "workspace.branches.compare.no-props")])]))) - -(mf/defc branch-compare-dialog* - {::mf/register modal/components - ::mf/register-as :branch-compare} - [{:keys [branch is-review]}] - (let [{:keys [status diff selected direction excluded]} (mf/deref branch-diff) - direction (or direction :branch->main) - incoming? (= direction :main->branch) - excluded (or excluded #{}) - - items - (mf/with-memo [diff] - (into (vec (:changes diff)) (:conflicts diff))) - - stats (:stats diff) - sel-item (when (and (some? selected) (< selected (count items))) - (nth items selected)) - - active-filter* (mf/use-state :all) - active-filter (deref active-filter*) - - ;; keep the original index for selection while filtering/grouping - filtered (mf/with-memo [items active-filter] - (filterv (fn [[_ it]] (status-matches? active-filter (item-status it))) - (map-indexed vector items))) - groups (mf/with-memo [filtered] - (group-changes filtered)) - - ;; the selection covers the clean changes alone (see - ;; `selectable-item?`), which is what the footer counts - all-keys (into #{} (comp (filter (fn [[_ it]] (selectable-item? it))) - (map (fn [[_ it]] (:key it)))) - (map-indexed vector items)) - kept-count (count (remove excluded all-keys)) - - on-close (mf/use-fn #(st/emit! (dwb/clear-diff-excluded) - (modal/hide))) - on-select (mf/use-fn #(st/emit! (dwb/select-diff-change %))) - on-toggle (mf/use-fn (fn [change-keys include?] - (st/emit! (dwb/toggle-diff-excluded change-keys include?)))) - on-filter (mf/use-fn (fn [f] (reset! active-filter* f))) - on-swap (mf/use-fn (mf/deps branch direction) - #(st/emit! (dwb/fetch-branch-diff - (:id branch) - (if incoming? :branch->main :main->branch)))) - on-merge (mf/use-fn (mf/deps branch excluded) - #(confirm-merge! branch excluded)) - on-update (mf/use-fn (mf/deps branch excluded) - #(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-export (mf/use-fn - (mf/deps diff branch) - (fn [] - (let [payload (clj->js {:stats stats - :changes (:changes diff) - :conflicts (:conflicts diff)}) - text (js/JSON.stringify payload nil 2) - blob (js/Blob. #js [text] #js {:type "application/json"}) - url (js/URL.createObjectURL blob) - a (.createElement js/document "a")] - (set! (.-href a) url) - (set! (.-download a) (dm/str (:name branch) "-diff.json")) - (.click a) - (js/URL.revokeObjectURL url))))] - - (mf/with-effect [(:id branch)] - (st/emit! (dwb/fetch-branch-diff (:id branch)))) - - ;; auto-select the first change once loaded - (mf/with-effect [status (count items)] - (when (and (= status :loaded) (nil? selected) (seq items)) - (st/emit! (dwb/select-diff-change 0)))) - - [:> modal* {:is-open true - :on-open-change (prui/on-dismiss on-close) - :size "xlarge"} - [:> prui/dialog-header* - {:icon i/switch - :title (tr "workspace.branches.compare.title") - :subtitle - (mf/html - [:div {:class (stl/css :compare-breadcrumb)} - (if incoming? - [:* - [:> branch-pill* {:label (tr "workspace.branches.main") :is-main true}] - [:button {:class (stl/css :breadcrumb-swap) - :title (tr "workspace.branches.compare.swap") - :aria-label (tr "workspace.branches.compare.swap") - :on-click on-swap} - [:> i/icon* {:icon-id i/arrow-long-right :size "s"}]] - ;; opened from a pull request, the branch takes the review blue - [:> branch-pill* {:label (:name branch) :is-review (boolean is-review)}]] - [:* - ;; opened from a pull request, the branch takes the review blue - [:> branch-pill* {:label (:name branch) :is-review (boolean is-review)}] - [:button {:class (stl/css :breadcrumb-swap) - :title (tr "workspace.branches.compare.swap") - :aria-label (tr "workspace.branches.compare.swap") - :on-click on-swap} - [:> i/icon* {:icon-id i/arrow-long-right :size "s"}]] - [:> branch-pill* {:label (tr "workspace.branches.main") :is-main true}]])])} - - (when stats - [:div {:class (stl/css :compare-stats)} - [:span {:class (stl/css :stat-badge :badge-added)} - (tr "workspace.branches.compare.stats.added" (i18n/c (:added stats)))] - [:span {:class (stl/css :stat-badge :badge-modified)} - (tr "workspace.branches.compare.stats.modified" (i18n/c (:modified stats)))] - [:span {:class (stl/css :stat-badge :badge-deleted)} - (tr "workspace.branches.compare.stats.deleted" (i18n/c (:deleted stats)))] - (when (pos? (:conflicts stats)) - [:span {:class (stl/css :stat-badge :badge-conflict)} - (tr "workspace.branches.compare.stats.conflicts" (i18n/c (:conflicts stats)))])])] - - [:> modal-content* {:class (stl/css :compare-content)} - (cond - (= status :loading) - [:div {:class (stl/css :compare-empty)} - [:> empty-state* {:icon i/switch - :text (tr "workspace.branches.compare.loading")}]] - - (= status :error) - [:div {:class (stl/css :compare-empty)} - [:> empty-state* {:icon i/triangle-alert - :text (tr "workspace.branches.compare.error")}]] - - (empty? items) - [:div {:class (stl/css :compare-empty)} - [:> empty-state* {:icon i/git-branch - :text (tr "workspace.branches.compare.empty")}]] - - :else - [:div {:class (stl/css :compare-body)} - [:div {:class (stl/css :compare-side)} - [:div {:class (stl/css :compare-filters)} - (for [[f label] compare-filters] - [:button {:key (name f) - :class (stl/css-case :filter-chip true - :is-active (= active-filter f) - :dot-added (= f :added) - :dot-modified (= f :modified) - :dot-deleted (= f :deleted)) - :on-click #(on-filter f)} - (when-not (= f :all) [:span {:class (stl/css :filter-dot)}]) - (tr label)])] - - [:div {:class (stl/css :compare-list)} - (for [{:keys [id name component? rows row-keys]} groups] - (let [all-included? (not-any? excluded row-keys)] - [:div {:class (stl/css :compare-group) :key id} - [:div {:class (stl/css :compare-group-head)} - (when (seq row-keys) - ;; the group checkbox selects or deselects the group's - ;; changes in one go - [:> checkbox* {:id (str "group-" id) - :checked all-included? - :on-change #(on-toggle row-keys (not all-included?))}]) - [:> i/icon* {:icon-id (if component? i/component i/document) :size "s"}] - [:span {:class (stl/css :compare-group-label)} name] - (let [freqs (frequencies (map (fn [[_ it]] (item-status it)) rows))] - [:span {:class (stl/css :compare-group-counts)} - (when (pos? (get freqs :added 0)) - [:span {:class (stl/css :count-added)} (dm/str "+" (get freqs :added))]) - (when (pos? (+ (get freqs :modified 0) (get freqs :conflict 0))) - [:span {:class (stl/css :count-modified)} - (dm/str "~" (+ (get freqs :modified 0) (get freqs :conflict 0)))]) - (when (pos? (get freqs :deleted 0)) - [:span {:class (stl/css :count-deleted)} (dm/str "−" (get freqs :deleted))])])] - [:ul {:class (stl/css :compare-group-items)} - (for [[idx item] rows] - [:> branch-compare-item* {:key idx - :item item - :index idx - :selected selected - :excluded? (contains? excluded (:key item)) - :on-select on-select - :on-toggle on-toggle}])]]))]] - - [:div {:class (stl/css :compare-detail)} - [:> branch-compare-detail* {:item sel-item}]]])] - - (when (= status :loaded) - (let [conflicts (long (or (:conflicts stats) 0)) - conflicts? (pos? conflicts) - total (long (+ (or (:added stats) 0) - (or (:modified stats) 0) - (or (:deleted stats) 0)))] - [:> modal-footer* - {:variant "split" - :class (stl/css :compare-footer) - :start - (mf/html - [:div {:class (stl/css :compare-footer-info)} - [:> i/icon* {:icon-id i/info :size "s"}] - [:span (tr "workspace.branches.compare.footer-changes" (i18n/c total))] - (when (seq all-keys) - [:span {:class (stl/css :compare-footer-selected)} - (tr "workspace.branches.compare.footer-selected" - (str kept-count) (i18n/c (count all-keys)))]) - (when conflicts? - [:span {:class (stl/css :compare-footer-conflicts)} - (dm/str " · " (tr "workspace.branches.compare.footer-conflicts" (i18n/c conflicts)))])]) - :end - (mf/html - [:* - [:> button* {:variant "secondary" - :icon i/download - :on-click on-export} - (tr "workspace.branches.compare.export")] - ;; one action that follows the current direction: update from main - ;; while viewing main's incoming changes, merge otherwise - (cond - conflicts? - [:> button* {:variant "primary" - :icon i/git-merge - :on-click on-resolve} - (tr "workspace.branches.conflicts.resolve")] - - incoming? - [:> button* {:variant "primary" - :icon i/status-update - :disabled (zero? kept-count) - :on-click on-update} - (tr "workspace.branches.update")] - - :else - [:> button* {:variant "primary" - :icon i/git-merge - :disabled (zero? kept-count) - :on-click on-merge} - (tr "workspace.branches.merge.action")])])}]))])) - -;; --- Resolve conflicts dialog - -(def ^:private conflict-reason->label - {:modify-modify "workspace.branches.conflicts.reason.modify-modify" - :add-add "workspace.branches.conflicts.reason.add-add" - :delete-modify "workspace.branches.conflicts.reason.delete-modify" - :modify-delete "workspace.branches.conflicts.reason.modify-delete"}) - -(mf/defc branch-conflict-item* - {::mf/private true} - [{:keys [conflict index selected resolution on-select]}] - (let [on-click (mf/use-fn (mf/deps index on-select) #(on-select index)) - resolved? (bm/conflict-resolved? conflict resolution) - mixed? (map? resolution) - icon-id (if (= :shape (:kind conflict)) - (shape-icon-id conflict) - (get kind->icon (:kind conflict) i/git-branch)) - type-lbl (type-label-key conflict)] - [:li {:class (stl/css-case :conflict-item true - :is-selected (= index selected) - :is-pending (not resolved?)) - :role "button" - :on-click on-click} - [:div {:class (stl/css :conflict-item-icon)} - [:> i/icon* {:icon-id icon-id}]] - [:div {:class (stl/css :conflict-item-body)} - [:span {:class (stl/css :conflict-item-label)} (:label conflict)] - [:span {:class (stl/css :conflict-item-subtitle)} - (cond-> "" - type-lbl (str (tr type-lbl) " · ") - :always (str (tr (if resolved? - "workspace.branches.conflicts.state-resolved" - "workspace.branches.conflicts.state-pending"))))]] - (cond - (not resolved?) - [:span {:class (stl/css :conflict-pill :pill-pending)} - [:> i/icon* {:icon-id i/triangle-alert :size "s"}]] - mixed? - [:span {:class (stl/css :conflict-pill :pill-mixed)} - [:span {:class (stl/css :pill-dot)}] - (tr "workspace.branches.conflicts.side-mixed")] - (= resolution :main) - [:span {:class (stl/css :conflict-pill :pill-main)} - [:span {:class (stl/css :pill-dot)}] - (tr "workspace.branches.conflicts.side-main")] - :else - [:span {:class (stl/css :conflict-pill :pill-branch)} - [:span {:class (stl/css :pill-dot)}] - (tr "workspace.branches.conflicts.side-branch")])])) - -;; --- Per-element visual previews --- - -(def ^:private renderable-shape-types - "Shape types we can faithfully render to SVG from a standalone shape map. - Images / svg-raw reference (possibly cross-file) media and fall back to a - type icon instead." - #{:rect :circle :frame :path :bool :text :group}) - -(def ^:private geometry-attrs - "Attrs whose change invalidates the cached `:selrect`/`:points`; when any - of them is taken from the branch in a per-attr resolution, the result - preview must adopt the branch's geometry so the framing stays correct." - #{:x :y :width :height :rotation :transform :transform-inverse :flip-x :flip-y}) - -(defn- shape-bounds - "Axis-aligned bounds framing a shape, taken from its rotated corner - `:points` when present, else its `:selrect`." - [shape] - (let [pts (:points shape)] - (if (and (sequential? pts) (seq pts)) - (let [xs (keep :x pts) ys (keep :y pts) - x (apply min xs) y (apply min ys)] - {:x x :y y :width (- (apply max xs) x) :height (- (apply max ys) y)}) - (let [sr (:selrect shape)] - {:x (:x sr 0) :y (:y sr 0) :width (:width sr 1) :height (:height sr 1)})))) - -(mf/defc shape-preview* - "Renders a single shape map to a small, fitted SVG using the real shape - renderers (so fills, gradients, strokes, corner radii, opacity and text - are accurate), without needing any workspace/objects context." - {::mf/private true} - [{:keys [shape]}] - (let [{:keys [x y width height]} (shape-bounds shape) - width (max 1 width) - height (max 1 height) - objects (mf/with-memo [shape] {(:id shape) shape}) - wrapper (mf/with-memo [objects] (render/shape-wrapper-factory objects))] - [:svg {:class (stl/css :preview-svg) - :viewBox (dm/str x " " y " " width " " height) - :preserveAspectRatio "xMidYMid meet" - :xmlns "http://www.w3.org/2000/svg" - :fill "none"} - [:> wrapper {:shape shape}]])) - -(mf/defc entity-preview* - "Visual preview of one side of a conflict, dispatching on entity kind: - shapes render to SVG, library colors to a swatch, typographies to a text - sample; everything else (and a nil/deleted side) shows a placeholder." - {::mf/private true} - [{:keys [kind value]}] - (cond - (nil? value) - [:div {:class (stl/css :preview-empty)} - [:> i/icon* {:icon-id i/delete :size "s"}]] - - (= kind :shape) - (if (contains? renderable-shape-types (:type value)) - [:> shape-preview* {:shape value}] - [:div {:class (stl/css :preview-empty)} - [:> i/icon* {:icon-id (shape-icon-id value)}]]) - - (= kind :color) - [:div {:class (stl/css :preview-swatch) - :style {:background (or (uc/color->background value) - (hex-color value) - "transparent")}}] - - (= kind :typography) - [:div {:class (stl/css :preview-type-sample) - :style {:font-family (:font-family value) - :font-weight (:font-weight value) - :font-style (:font-style value)}} - "Ag"] - - :else - [:div {:class (stl/css :preview-empty)} - [:> i/icon* {:icon-id (get kind->icon kind i/git-branch)}]])) - -(defn- merged-entity - "Synthesize the resulting entity for the RESULT preview from the `main` - and `branch` entities, the conflict's changed-attr keys and the current - `res` (a `:main`/`:branch` keyword or a per-attr `{attr -> side}` map)." - [main branch attrs res] - (cond - (= res :branch) branch - (or (nil? res) (= res :main)) main - (map? res) - (let [merged (reduce (fn [acc k] - (if (= :branch (get res k)) - (assoc acc k (get branch k)) - acc)) - main attrs)] - (if (and (map? main) (map? branch) - (some (fn [k] (and (contains? geometry-attrs k) (= :branch (get res k)))) attrs)) - (assoc merged - :selrect (:selrect branch) - :points (:points branch) - :transform (:transform branch) - :transform-inverse (:transform-inverse branch)) - merged)) - :else main)) - -(defn- attr-choice - "Currently-selected side (`:main`/`:branch`) for `attr` under resolution - `res`, or nil when undecided (so the property row shows no selection until - the user acts)." - [res attr] - (cond - (= res :branch) :branch - (= res :main) :main - (map? res) (get res attr) - :else nil)) - -(mf/defc conflict-preview-row* - "The BASE · MAIN · BRANCH · RESULT preview strip on top of the detail - panel. RESULT updates live as per-property choices change." - {::mf/private true} - [{:keys [conflict resolution base-sub main-sub branch-sub]}] - (let [kind (:kind conflict) - attrs (keys (:changed-attrs conflict)) - result (merged-entity (:main conflict) (:branch conflict) attrs resolution) - cols [[:base "workspace.branches.conflicts.card.base" base-sub (:base conflict)] - [:main "workspace.branches.conflicts.card.main" main-sub (:main conflict)] - [:branch "workspace.branches.conflicts.card.branch" branch-sub (:branch conflict)] - [:result "workspace.branches.conflicts.card.result" nil result]]] - [:div {:class (stl/css :preview-row)} - (for [[col-key label sub value] cols] - [:div {:class (stl/css-case :preview-col true :is-result (= col-key :result)) - :key (name col-key)} - [:span {:class (stl/css :preview-col-label)} (tr label)] - [:div {:class (stl/css :preview-box)} - [:> entity-preview* {:kind kind :value value}]] - (when sub [:span {:class (stl/css :preview-col-sub)} sub])])])) - -(mf/defc conflict-prop-row* - "One property of a conflict: its label and two clickable value chips - (MAIN / BRANCH). Clicking a chip keeps that side for this property only." - {::mf/private true} - [{:keys [id attr main branch choice attrs]}] - (let [on-main (mf/use-fn (mf/deps id attr attrs) - #(st/emit! (dwb/set-conflict-attr-resolution id attr :main attrs))) - on-branch (mf/use-fn (mf/deps id attr attrs) - #(st/emit! (dwb/set-conflict-attr-resolution id attr :branch attrs)))] - [:div {:class (stl/css :prop-pick-row)} - [:span {:class (stl/css :prop-pick-name)} (attr-label attr)] - [:button {:class (stl/css-case :prop-pick true :is-chosen (= choice :main)) - :on-click on-main} - [:> compare-value-chip* {:value main :tone :main}]] - [:button {:class (stl/css-case :prop-pick true :is-chosen (= choice :branch)) - :on-click on-branch} - [:> compare-value-chip* {:value branch :tone :branch}]]])) - -(mf/defc branch-conflicts-dialog* - {::mf/register modal/components - ::mf/register-as :branch-conflicts} - [{:keys [branch mode conflicts excluded]}] - (let [{:keys [diff selected resolutions status]} (mf/deref branch-diff) - - loading? (= status :loading) - - ;; merge mode only: whether to keep the branch archived after merge - keep?* (mf/use-state false) - keep? (deref keep?*) - on-toggle-keep (mf/use-fn #(swap! keep?* not)) - - diff-meta (:meta diff) - ;; per-side header subtitle: base is pinned at branch creation, main - ;; and branch show how recent each one is ("3 hours ago"); the branch - ;; also names the author as "you" (it's always the current user). - base-sub (tr "workspace.branches.conflicts.card.base-meta") - main-sub (some-> (:main-at diff-meta) ct/inst ct/timeago) - branch-sub (let [ago (some-> (:branch-at diff-meta) ct/inst ct/timeago) - you (tr "workspace.branches.conflicts.card.you")] - (if ago (str ago " · " you) you)) - - ;; 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 (count (filterv #(bm/conflict-resolved? % (get resolutions (:id %))) conflicts)) - pending (- total resolved) - all-done? (and (pos? total) (zero? pending)) - - sel-idx (min (or selected 0) (max 0 (dec total))) - sel (when (seq conflicts) (nth conflicts sel-idx)) - sel-res (when sel (get resolutions (:id sel))) - sel-type (when sel (type-label-key sel)) - sel-reason (when sel (get conflict-reason->label (:reason sel))) - sel-icon (when sel (if (= :shape (:kind sel)) - (shape-icon-id sel) - (get kind->icon (:kind sel) i/git-branch))) - - on-close (mf/use-fn #(st/emit! (modal/hide))) - on-select (mf/use-fn #(st/emit! (dwb/select-diff-change %))) - on-all-main (mf/use-fn #(st/emit! (dwb/set-all-resolutions :main))) - on-all-branch (mf/use-fn #(st/emit! (dwb/set-all-resolutions :branch))) - on-use-main (mf/use-fn (mf/deps sel) - #(when sel (st/emit! (dwb/set-conflict-resolution (:id sel) :main)))) - on-use-branch (mf/use-fn (mf/deps sel) - #(when sel (st/emit! (dwb/set-conflict-resolution (:id sel) :branch)))) - on-apply (mf/use-fn (mf/deps branch resolutions mode keep? excluded) - #(st/emit! (if (= mode :update) - (dwb/update-branch-from-main branch {:resolutions resolutions - :excluded excluded}) - (dwb/merge-branch branch {:resolutions resolutions - :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: - ;; an update shows main's incoming changes, and the key-space of its - ;; payload has to match the one the normalization above assumes - (mf/with-effect [(:id branch) mode] - (st/emit! (dwb/fetch-branch-diff (:id branch) - (if (= mode :update) :main->branch :branch->main)))) - - [:> modal* {:is-open true - :on-open-change (prui/on-dismiss on-close) - :size "xlarge"} - [:> prui/dialog-header* {:icon i/triangle-alert - :tone :warning - :title (tr "workspace.branches.conflicts.title") - :subtitle (tr "workspace.branches.conflicts.subtitle" (:name branch))} - [:span {:class (stl/css :conflicts-progress)} - (tr "workspace.branches.conflicts.progress" (str resolved) (str total))]] - - ;; the toolbar sits inside the content, above the panes, so - ;; `modal*` keeps its three rows: header, content and footer - [:> modal-content* {:class (stl/css :compare-content)} - [:div {:class (stl/css :conflicts-toolbar)} - [:span {:class (stl/css :bulk-label)} (tr "workspace.branches.conflicts.bulk-label")] - [:> button* {:variant "secondary" :icon i/git-branch :on-click on-all-main} - (tr "workspace.branches.conflicts.all-main")] - [:> button* {:variant "secondary" :icon i/git-branch :on-click on-all-branch} - (tr "workspace.branches.conflicts.all-branch")] - (when (pos? pending) - [:span {:class (stl/css :conflicts-pending)} - (tr "workspace.branches.conflicts.pending" (str pending))])] - - (cond - loading? - [:div {:class (stl/css :compare-empty)} - [:> empty-state* {:icon i/git-merge - :text (tr "workspace.branches.compare.loading")}]] - - (empty? conflicts) - [:div {:class (stl/css :compare-empty)} - [:> empty-state* {:icon i/git-merge - :text (tr "workspace.branches.conflicts.empty")}]] - - :else - [:div {:class (stl/css :compare-body)} - [:div {:class (stl/css :conflicts-list-side)} - [:span {:class (stl/css :conflicts-list-head)} - (tr "workspace.branches.conflicts.list-head" (str total))] - [:ul {:class (stl/css :compare-list)} - (for [[idx c] (map-indexed vector conflicts)] - [:> branch-conflict-item* {:key idx - :conflict c - :index idx - :selected sel-idx - :resolution (get resolutions (:id c)) - :on-select on-select}])]] - - [:div {:class (stl/css :compare-detail)} - (when sel - (let [sel-attrs (:changed-attrs sel) - attr-keys (keys sel-attrs) - has-attrs? (seq sel-attrs)] - [:* - [:div {:class (stl/css :conflict-detail-head)} - [:div {:class (stl/css :conflict-detail-titlerow)} - [:div {:class (stl/css :conflict-detail-icon)} - [:> i/icon* {:icon-id sel-icon}]] - [:h3 {:class (stl/css :conflict-detail-title)} (:label sel)] - [:div {:class (stl/css :conflict-global-actions)} - [:> button* {:variant (if (= sel-res :main) "primary" "secondary") - :on-click on-use-main} - (tr "workspace.branches.conflicts.use-main")] - [:> button* {:variant (if (= sel-res :branch) "primary" "secondary") - :on-click on-use-branch} - (tr "workspace.branches.conflicts.use-branch")]]] - [:span {:class (stl/css :conflict-detail-subtitle)} - (cond-> "" - sel-type (str (tr sel-type)) - sel-reason (str " - " (tr sel-reason)))]] - - [:> conflict-preview-row* {:conflict sel - :resolution sel-res - :base-sub base-sub - :main-sub main-sub - :branch-sub branch-sub}] - - (if has-attrs? - [:div {:class (stl/css :prop-table)} - [:div {:class (stl/css :prop-table-head)} - [:span {:class (stl/css :prop-table-th)} - (tr "workspace.branches.conflicts.prop-header")] - [:span {:class (stl/css :prop-table-th)} - (tr "workspace.branches.conflicts.card.main")] - [:span {:class (stl/css :prop-table-th)} - (tr "workspace.branches.conflicts.card.branch")]] - (for [[attr {:keys [main branch]}] sel-attrs] - [:> conflict-prop-row* {:key (str attr) - :id (:id sel) - :attr attr - :main main - :branch branch - :choice (attr-choice sel-res attr) - :attrs attr-keys}])] - - [:p {:class (stl/css :conflict-whole-hint)} - (tr "workspace.branches.conflicts.whole-hint")])]))]])] - - [:> modal-footer* - {:variant "split" - :class (stl/css :compare-footer) - :start - (mf/html - [:div {:class (stl/css :compare-footer-info)} - (if all-done? - [:* - [:> i/icon* {:icon-id i/tick :size "s"}] - (tr "workspace.branches.conflicts.footer-ready")] - [:* - [:span {:class (stl/css :compare-footer-conflicts)} - [:> i/icon* {:icon-id i/triangle-alert :size "s"}]] - [:span {:class (stl/css :compare-footer-conflicts)} - (tr "workspace.branches.conflicts.footer-pending" (str pending))]])]) - :end - (mf/html - [:* - ;; applying a MERGE deletes the branch unless the user opts to - ;; keep it archived — same choice the plain merge dialog offers - (when (not= mode :update) - [:> checkbox* {:id "conflicts-archive-branch" - :label (tr "workspace.branches.merge.archive-label") - :checked keep? - :on-change on-toggle-keep}]) - [:> button* {:variant "secondary" :on-click on-close} (tr "labels.cancel")] - [:> button* {:variant "primary" - :icon i/git-merge - :disabled (not all-done?) - :on-click on-apply} - (tr "workspace.branches.conflicts.apply")]])}]])) - ;; --- Branch context banner (shown while editing a branch) (defn- publish-banner-clearance! @@ -1951,7 +981,7 @@ on-menu-change (mf/use-fn #(reset! menu-open* %)) on-compare (mf/use-fn (mf/deps ctx) #(modal/show! :branch-compare {:branch ctx})) - on-update (mf/use-fn (mf/deps ctx) #(confirm-update! ctx)) + on-update (mf/use-fn (mf/deps ctx) #(brc/confirm-update! ctx)) on-open-main (mf/use-fn (mf/deps ctx) #(st/emit! (dwb/open-branch (:source-file-id ctx)))) on-restore (mf/use-fn (mf/deps ctx) #(st/emit! (dwb/archive-branch (:id ctx) false))) on-materialize (mf/use-fn (mf/deps ctx) #(confirm-materialize! ctx)) @@ -1962,7 +992,7 @@ (mf/deps ctx) (fn [_] (reset! menu-open* false) - (confirm-merge! ctx))) + (brc/confirm-merge! ctx))) on-request-review (mf/use-fn @@ -2054,7 +1084,7 @@ archived? [:div {:class (stl/css :branch-banner-actions)} [:> button* {:variant "secondary" - :icon i/switch + :icon i/git-compare-arrows :on-click on-compare} (tr "workspace.branches.compare")] [:> button* {:variant "secondary" @@ -2070,7 +1100,7 @@ (let [conflicts (or (:conflicts ctx) 0)] [:div {:class (stl/css :branch-banner-actions)} [:> button* {:variant "secondary" - :icon i/switch + :icon i/git-compare-arrows :on-click on-compare} (tr "workspace.branches.compare")] diff --git a/frontend/src/app/main/ui/workspace/sidebar/branches.scss b/frontend/src/app/main/ui/workspace/sidebar/branches.scss index 41a3aa8715..c26f27e237 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/branches.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/branches.scss @@ -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 { diff --git a/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs index e511624e85..66612173e4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs @@ -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")] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 836b43b7d7..77678bb47c 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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 diff --git a/frontend/test/frontend_tests/ui/branch_review_common_test.cljs b/frontend/test/frontend_tests/ui/branch_review_common_test.cljs new file mode 100644 index 0000000000..10d466ace9 --- /dev/null +++ b/frontend/test/frontend_tests/ui/branch_review_common_test.cljs @@ -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)))) diff --git a/frontend/test/frontend_tests/ui/branches_compare_labels_test.cljs b/frontend/test/frontend_tests/ui/branches_compare_labels_test.cljs index be088040b6..f5363d4c72 100644 --- a/frontend/test/frontend_tests/ui/branches_compare_labels_test.cljs +++ b/frontend/test/frontend_tests/ui/branches_compare_labels_test.cljs @@ -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 diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 841378027e..7661d6cf08 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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"