✨ Move branching into the workspace header (#12359)

* ✨ Move the branch switcher to the workspace header

* ✨ Add branch DS components and the all-branches dialog

* 💄 Polish the branch switcher and its menu entry

* ✨ Keep the dashboard to switching branches

* 💄 Polish the branch and pull request review UI

* ♻️ Move the branching UI onto the design system

* 🐛 Address the review of the header branch switcher

The bottom banner turned the nil counts that get-file-branch-info sends
for a branch it cannot compare into zeros, so it said "Up to date with
main". It now says "Couldn't compare this branch with main" in the
warning colour, as the header row already did.

During a review the review banner replaces the branch banner, which was
the only component loading the branch context. The header row now loads
it too, so a review link opened fresh shows the branch under review
instead of "Main".

On a branch, "New branch" in the all-branches dialog is disabled with a
tooltip saying branches are created from main: the server refuses to
branch a branch.

With "Try interactions" gone, the create-review note says reviewers
open the live branch and View mode plays it, and the unused
open-pull-request-viewer event goes.

The pull request specs follow the new banners, and three tests cover
the fixes above.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Co-authored-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
Juan de la Cruz 2026-10-22 16:50:19 +02:00 committed by GitHub
parent 4c86699424
commit 720b2f193d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 3146 additions and 2055 deletions

View File

@ -0,0 +1,15 @@
{
"~:id": "~uc7ce0794-0992-8105-8004-38f280443998",
"~:branch-file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
"~:source-file-id": "~uc7ce0794-0992-8105-8004-38f280443997",
"~:name": "checkout",
"~:description": null,
"~:status": "open",
"~:created-by": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
"~:created-at": "~m1713536343369",
"~:updated-at": "~m1713873823633",
"~:source-name": "New File 1",
"~:ahead": null,
"~:behind": null,
"~:conflicts": null
}

View File

@ -32,18 +32,22 @@ test("opening a file with pr-id shows the review sandbox banner", async ({
await expect(page.getByText("Reviewing pull request")).toBeVisible();
await expect(page.getByText("Checkout redesign").first()).toBeVisible();
await expect(page.getByText("checkout → New File 1")).toBeVisible();
// the route reads as two pills: the branch under review, then main
const banner = page.getByText("Reviewing pull request").locator("../../..");
await expect(banner.getByText("checkout", { exact: true })).toBeVisible();
await expect(banner.getByText("Main", { exact: true })).toBeVisible();
// only the essential actions are visible buttons...
await expect(page.getByRole("button", { name: "Details" })).toBeVisible();
await expect(page.getByRole("button", { name: "Compare" })).toBeVisible();
await expect(page.getByRole("button", { name: "Details" })).toBeHidden();
// ...the rest live behind the kebab menu
const banner = page.getByText("Reviewing pull request").locator("../../..");
await banner.getByRole("button", { name: "Options" }).click();
await expect(page.getByText("Try interactions")).toBeVisible();
await expect(page.getByText("Details")).toBeVisible();
await expect(page.getByText("Merge to main")).toBeVisible();
await expect(page.getByText("Exit review")).toBeVisible();
await expect(page.getByText("Try interactions")).toBeHidden();
});
test("the compare dialog shows the proposed changes", async ({ page }) => {
@ -111,15 +115,16 @@ test("closing the pull request asks for confirmation", async ({ page }) => {
);
await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
await page.getByRole("button", { name: "Details" }).click();
await page.getByRole("button", { name: "Close pull request" }).click();
const banner = page.getByText("Reviewing pull request").locator("../../..");
await banner.getByRole("button", { name: "Options" }).click();
await page.getByText("Cancel review").click();
// nothing is closed yet: a confirmation dialog takes over
await expect(page.getByText("will disappear")).toBeVisible();
await expect(page.getByText("will be cancelled")).toBeVisible();
// confirming closes the pull request; being inside its sandbox, the
// user is taken back to the file (no pr-id in the url)
await page.getByRole("button", { name: "Close pull request" }).click();
await page.getByRole("button", { name: "Cancel pull request" }).click();
await expect(page).not.toHaveURL(/pr-id/);
});
@ -145,7 +150,7 @@ test("on a branch, requesting a review is the primary action", async ({
page.getByRole("button", { name: "Merge to main" }),
).toBeHidden();
const banner = page.getByText("On branch").locator("../../..");
const banner = page.locator("#branch-banner");
await banner.getByRole("button", { name: "Options" }).click();
await page.getByText("Merge to main").click();
await expect(page.getByText("Merge into main")).toBeVisible();
@ -166,3 +171,56 @@ test("exiting the review takes the user back to the file", async ({ page }) => {
await expect(page).not.toHaveURL(/pr-id/);
await expect(page.getByText("Reviewing pull request")).toBeHidden();
});
test("during a review the header names the branch under review", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
await setupPullRequestSandbox(workspacePage);
// the review link opens the author's branch
await workspacePage.mockRPC(
"get-file-branch-info?file-id=*",
"pull-requests/get-file-branch-info-branch.json",
);
await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
// the review banner replaces the branch banner, so the header loads the
// branch context itself
await expect(page.getByText("Reviewing pull request")).toBeVisible();
await expect(page.getByTitle("On branch checkout from main")).toBeVisible();
});
test("a branch that cannot be compared with main says so", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupPullRequestSandbox(workspacePage);
// the backend answers nil counts when it cannot compare the branch
await workspacePage.mockRPC(
"get-file-branch-info?file-id=*",
"pull-requests/get-file-branch-info-uncompared.json",
);
await workspacePage.goToWorkspace();
const banner = page.locator("#branch-banner");
await expect(
banner.getByText("Couldn't compare this branch with main"),
).toBeVisible();
await expect(banner.getByText("Up to date with main")).toBeHidden();
});
test("on a branch, the all-branches dialog offers no new branch", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
await setupPullRequestSandbox(workspacePage);
await workspacePage.mockRPC(
"get-file-branch-info?file-id=*",
"pull-requests/get-file-branch-info-branch.json",
);
await workspacePage.goToWorkspace();
await page.getByTitle("On branch checkout from main").click();
await page.getByText("All branches").click();
// branches only grow from main: the button stays, disabled
await expect(page.getByRole("button", { name: "New branch" })).toBeDisabled();
});

View File

@ -9,17 +9,14 @@
patterns in `app.main.data.workspace.versions`."
(:require
[app.config :as cf]
[app.main.broadcast :as mbc]
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dwp]
[app.main.data.workspace.layout :as layout]
[app.main.repo :as rp]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.storage :as storage]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -56,7 +53,6 @@
(declare fetch-branches)
(declare fetch-branching-limits)
(declare fetch-branch-context)
(declare show-branches-panel)
(defn init-branches-state
[]
@ -243,8 +239,7 @@
(defn create-branch
"Create a branch from a file. With no `file-id`, branches the currently
open file (force-persisting first so the merge base captures the latest
edits) and jumps into the new branch, keeping the branches panel open
so the user lands on the branch with full context. With an explicit
edits) and jumps into the new branch. With an explicit
`file-id` (e.g. from the dashboard, where the file is not open),
creates it directly and opens the new branch. Errors surface as a
toast; the caller closes the dialog."
@ -282,65 +277,67 @@
(rx/concat
(rx/of (ntf/success (tr "workspace.branches.create.success" name)))
(if from-ws?
;; jump into the new branch keeping the branches
;; panel open (the layout survives in-app navigation;
;; show-branches-panel pins the tab selection), and
;; refresh the list so the new branch is included
(rx/of (open-branch branch-file-id)
(show-branches-panel)
(fetch-branches))
;; jump into the new branch; the branch switcher in
;; the header shows where the user landed
(rx/of (open-branch branch-file-id))
;; from dashboard: jump into the new branch
(rx/of (dcm/go-to-workspace :file-id branch-file-id))))))
(rx/catch
(fn [cause]
(rx/of (refusal-notification cause (tr "workspace.branches.create.error"))))))))))))
(defn toggle-branch-switcher
"Open or close the branch switcher popover of the workspace header. With
no argument it flips the current state; `open?` forces it."
([] (toggle-branch-switcher nil))
([open?]
(ptk/reify ::toggle-branch-switcher
ptk/UpdateEvent
(update [_ state]
(let [open? (if (some? open?)
(boolean open?)
(not (:workspace-branch-switcher state)))]
(assoc state :workspace-branch-switcher open?))))))
(defn has-branches?
"True when the open file takes part in branching: it is a branch, or it
is main and has at least one branch (archived ones count, so they stay
reachable). The header only shows the branch switcher then."
[state]
(or (some? (get-in state [:workspace-branch-context :info]))
(boolean (seq (get-in state [:workspace-branches :data])))))
(defn open-branch
"Navigate to the branch file as a normal workspace file. Switching
branch must never lose the branches panel: the layout flag survives
in-app navigation, but side effects of the transition can still close
it (e.g. the version-history interrupt watcher armed by the header
button), which makes the switch confusing. So once the target
workspace finishes initializing, the panel + Branches tab are
re-asserted; when they are already open this is an idempotent no-op."
"Navigate to the branch file as a normal workspace file. The branch
switcher and any open dialog close; once there, the header shows the
branch."
[branch-file-id]
(assert (uuid? branch-file-id) "expected valid uuid for `branch-file-id`")
(ptk/reify ::open-branch
ptk/WatchEvent
(watch [_ _ stream]
(rx/merge
(rx/of (dcm/go-to-workspace :file-id branch-file-id))
;; keyword literal to avoid requiring the whole app.main.data.workspace ns
(->> stream
(rx/filter (ptk/type? :app.main.data.workspace/workspace-initialized))
(rx/take 1)
(rx/mapcat (fn [_] (rx/of (show-branches-panel)
(fetch-branches)))))))))
ptk/UpdateEvent
(update [_ state]
(assoc state :workspace-branch-switcher false))
(def history-sidebar-tab-key
"Storage + broadcast key for the selected tab of the version-history
sidebar. Shared with `app.main.ui.workspace.sidebar`, which reads it
through `hooks/use-shared-state` so this event can switch the active
tab even when the panel is already open."
::history-sidebar-tab)
(defn show-branches-panel
"Open the version-history sidebar and switch it to the Branches tab.
No-op when the branching feature flag is disabled (the tab does not
exist in that case). Writing storage covers a closed→open panel; the
broadcast emit covers an already-open one."
[]
(ptk/reify ::show-branches-panel
ptk/WatchEvent
(watch [_ _ _]
(when (contains? cf/flags :branching)
(rx/of (layout/toggle-layout-flag :document-history :force? true))))
;; the all-branches dialog may be the one that asked
(rx/of (modal/hide)
(dcm/go-to-workspace :file-id branch-file-id)))))
ptk/EffectEvent
(effect [_ _ _]
(defn show-branches
"The entry point of the File menu and the Ctrl/Cmd+Alt+B shortcut. A
file that has branches gets the branch switcher; one with none gets the
create-branch dialog, since the switcher only exists once a branch does.
No-op when the branching feature flag is disabled."
[]
(ptk/reify ::show-branches
ptk/WatchEvent
(watch [_ state _]
(when (contains? cf/flags :branching)
(swap! storage/user assoc history-sidebar-tab-key "branches")
(mbc/emit! history-sidebar-tab-key "branches")))))
(if (has-branches? state)
(rx/of (toggle-branch-switcher true))
(let [file (get-in state [:files (:current-file-id state)])]
(rx/of (modal/show :create-branch {:file-name (:name file)}))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; COMPARE (read-only 3-way diff)

View File

@ -14,8 +14,6 @@
the outdated flag, not the canvas. Mirrors the patterns of
`app.main.data.workspace.branches`."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf]
@ -275,24 +273,6 @@
(dissoc :page-id))]
(rx/of (rt/nav :workspace params))))))
(defn open-pull-request-viewer
"Open the review sandbox in the interactions viewer (new window): the
viewer bundle serves the pull request's pinned snapshot, so reviewers
can play the prototype exactly as it was published for review."
[pr]
(assert (uuid? (:id pr)) "expected a pull request row with a valid `:id`")
(ptk/reify ::open-pull-request-viewer
ptk/WatchEvent
(watch [_ state _]
(let [params {:file-id (:source-file-id pr)
:page-id (:current-page-id state)
:pr-id (:id pr)
:section :interactions}]
(rx/of (rt/nav :viewer
(d/without-nils params)
{::rt/new-window true
::rt/window-name (dm/str "viewer-" (:source-file-id pr))}))))))
(defn- set-pull-request-preview
[preview]
(ptk/reify ::set-pull-request-preview

View File

@ -663,7 +663,7 @@
:command (ds/ca-mod "b")
:subsections [:panels]
:fn #(emit-when-no-readonly
(dwb/show-branches-panel))}
(dwb/show-branches))}
:toggle-colorpalette {:tooltip (ds/alt "P")
:label (fn [] (tr "shortcuts.toggle-colorpalette"))

View File

@ -5,18 +5,18 @@
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.dashboard.branches-popover
"Dashboard file-card popover that lists a file's branches and lets you
open one or create a new branch without entering the file."
"Dashboard file-card popover that lists a file and its branches and
opens the one picked. Branches are created from inside the file."
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.main.data.modal :as modal]
[app.main.data.workspace.branches :as dwb]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.product.branch-row :refer [branch-row*]]
[app.main.ui.ds.product.branch-status :refer [branch-status*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[okulary.core :as l]
@ -43,14 +43,6 @@
on-close (mf/use-fn #(reset! show? false))
on-create
(mf/use-fn
(mf/deps file)
(fn [event]
(dom/stop-propagation event)
(reset! show? false)
(modal/show! :create-branch {:file-name (:name file) :file-id (:id file)})))
on-open-branch
(mf/use-fn
(fn [event]
@ -67,24 +59,25 @@
[:> i/icon* {:icon-id i/git-branch :size "s"}]
[:span (dm/str n)]]
;; a switcher only: branches are created from inside the file
[:& dropdown {:show (deref show?) :on-close on-close}
[:div {:class (stl/css :branches-popover)}
[:div {:class (stl/css :branches-popover)
:role "dialog"
:aria-label (tr "dashboard.branches-badge" (dm/str n))}
[:> branch-row* {:label (tr "workspace.branches.main")
:data-branch (dm/str (:id file))
:on-click on-open-branch}
(tr "workspace.branches.main-subtitle")]
[:div {:class (stl/css :branches-popover-section)}
[:span (tr "workspace.branches.menu")]
[:span (dm/str (count branches))]]
[:ul {:class (stl/css :branches-popover-list)}
(for [b branches]
[:li {:key (dm/str (:id b))
:class (stl/css :branches-popover-item)
:role "button"
:data-branch (dm/str (:branch-file-id b))
:on-click on-open-branch}
[:> i/icon* {:icon-id i/git-branch :size "s"}]
[:span {:class (stl/css :branches-popover-name)} (:name b)]
(if (or (:diff-error b) (nil? (:ahead b)))
;; the comparison could not be made: the warning stands
;; where the counts would, because zeros read as "in sync"
[:span {:class (stl/css :branches-popover-error)
:title (tr "workspace.branches.counts.error")}
[:> i/icon* {:icon-id i/triangle-alert :size "s"}]]
[:span {:class (stl/css :branches-popover-counts)}
(dm/str "↑" (:ahead b) " ↓" (:behind b))])])]
[:> button* {:variant "primary" :icon i/add :on-click on-create}
(tr "workspace.branches.new")]]]]))
[:li {:key (dm/str (:id b))}
[:> branch-row* {:label (:name b)
:data-branch (dm/str (:branch-file-id b))
:on-click on-open-branch}
[:> branch-status* {:ahead (:ahead b)
:behind (:behind b)
:conflicts (:conflicts b)
:has-error (boolean (or (:diff-error b) (nil? (:ahead b))))}]]])]]]]))

View File

@ -4,89 +4,68 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "ds/typography.scss" as t;
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as *;
// floats over the bottom-right corner of the file thumbnail
.branches-popover-wrap {
position: absolute;
inset-inline-start: deprecated.$s-12;
inset-block-start: deprecated.$s-12;
inset-inline-start: var(--sp-m);
inset-block-start: var(--sp-m);
// TODO: no DS z-index token for 1.
z-index: 1;
}
.branches-badge {
@include t.use-typography("body-small");
@include use-typography("body-small");
display: inline-flex;
align-items: center;
gap: var(--sp-xxs);
block-size: $sz-32;
padding: var(--sp-xs) var(--sp-s);
border: 2px solid var(--color-background-quaternary);
border-radius: deprecated.$br-8;
height: 32px;
border: $b-2 solid var(--color-background-quaternary);
border-radius: $br-8;
color: var(--color-foreground-secondary);
background-color: var(--color-background-tertiary);
&:hover {
background-color: var(--color-background-tertiary);
}
}
// out of the flow, under the badge: the badge never moves when it opens,
// and the popover is only as wide as its longest branch name allows
.branches-popover {
position: absolute;
inset-block-start: calc(100% + var(--sp-xs));
inset-inline-start: 0;
display: flex;
flex-direction: column;
gap: var(--sp-s);
min-inline-size: 240px;
gap: var(--sp-xxs);
inline-size: max-content;
min-inline-size: $sz-224;
max-inline-size: $sz-284;
padding: var(--sp-s);
border-radius: 8px;
border: 1px solid var(--color-background-quaternary);
background-color: var(--color-background-secondary);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.25);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
box-shadow: var(--el-shadow-dark);
}
.branches-popover-section {
@include use-typography("headline-small");
display: flex;
justify-content: space-between;
padding: var(--sp-s) var(--sp-s) var(--sp-xs);
color: var(--color-foreground-secondary);
}
.branches-popover-list {
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
margin: 0;
padding: 0;
list-style: none;
max-block-size: 280px;
max-block-size: $sz-284;
overflow-y: auto;
}
.branches-popover-item {
display: flex;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-xs) var(--sp-s);
border-radius: 6px;
cursor: pointer;
color: var(--color-foreground-primary);
&:hover {
background-color: var(--color-background-tertiary);
}
}
.branches-popover-name {
@include t.use-typography("body-medium");
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.branches-popover-counts {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
}
.branches-popover-error {
display: inline-flex;
align-items: center;
color: var(--color-foreground-warning);
}

View File

@ -4,6 +4,8 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/_borders.scss" as *;
@use "ds/_utils.scss" as *;
@use "refactor/common-refactor.scss" as deprecated;
.dashboard-comments-section {
@ -118,44 +120,53 @@
.review-requests {
display: flex;
flex-direction: column;
gap: deprecated.$s-4;
padding: deprecated.$s-8 deprecated.$s-12;
border-bottom: deprecated.$s-1 solid var(--color-background-quaternary);
gap: var(--sp-xs);
padding: var(--sp-s) var(--sp-m);
border-block-end: $b-1 solid var(--color-background-quaternary);
}
.review-requests-title {
// TODO: No exact DS typography match found.
// Closest match: headline-small (size 11 vs 12; family, weight and
// line-height are inherited here).
font-size: px2rem(11);
color: var(--color-foreground-secondary);
font-size: deprecated.$fs-11;
text-transform: uppercase;
}
.review-request-item {
--review-request-item-background-color: none;
display: flex;
flex-direction: column;
gap: deprecated.$s-2;
padding: deprecated.$s-8;
gap: var(--sp-xxs);
padding: var(--sp-s);
border: none;
border-radius: deprecated.$s-8;
background: none;
border-radius: $br-8;
background: var(--review-request-item-background-color);
cursor: pointer;
text-align: start;
&:hover {
background-color: var(--color-background-tertiary);
--review-request-item-background-color: var(--color-background-tertiary);
}
}
.review-request-name {
// TODO: No exact DS typography match found.
// Closest match: body-medium (only the size is set here).
font-size: px2rem(14);
color: var(--color-foreground-primary);
font-size: deprecated.$fs-14;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.review-request-route {
// TODO: No exact DS typography match found.
// Closest match: headline-small (size 11 vs 12; only the size is set here).
font-size: px2rem(11);
color: var(--color-foreground-secondary);
font-size: deprecated.$fs-11;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@ -7,7 +7,6 @@
(ns app.main.ui.dashboard.file-menu
(:require
[app.common.data :as d]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.event :as-alias ev]
@ -302,17 +301,6 @@
[:> menu-item* {:id "file-duplicate" :on-action on-duplicate}
(tr "dashboard.duplicate")])
(when (and (not is-lib-page?)
(not is-search-page?)
can-edit
(contains? cf/flags :branching))
[:> menu-item* {:id "file-create-branch"
:on-action (fn []
(modal/show! :create-branch
{:file-name (:name file)
:file-id (:id file)}))}
(tr "dashboard.create-branch")])
(when (and (not is-lib-page?)
(not is-search-page?)
(or (seq current-projects) (seq other-teams))

View File

@ -415,7 +415,7 @@ $thumbnail-default-height: px2rem(168);
align-items: center;
margin-inline-end: var(--sp-xs);
padding: var(--sp-xxs) var(--sp-xs);
border-radius: 8px;
border-radius: $br-8;
color: var(--color-accent-primary);
background-color: var(--color-background-tertiary);
}

View File

@ -33,6 +33,10 @@
[app.main.ui.ds.notifications.shared.notification-pill :refer [notification-pill*]]
[app.main.ui.ds.notifications.toast :refer [toast*]]
[app.main.ui.ds.product.avatar :refer [avatar*]]
[app.main.ui.ds.product.branch-chip :refer [branch-chip*]]
[app.main.ui.ds.product.branch-pill :refer [branch-pill*]]
[app.main.ui.ds.product.branch-row :refer [branch-row*]]
[app.main.ui.ds.product.branch-status :refer [branch-status*]]
[app.main.ui.ds.product.cta :refer [cta*]]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
@ -81,6 +85,10 @@
:Swatch swatch*
:Cta cta*
:Avatar avatar*
:BranchChip branch-chip*
:BranchPill branch-pill*
:BranchRow branch-row*
:BranchStatus branch-status*
:Milestone milestone*
:MilestoneGroup milestone-group*
:Date date*

View File

@ -78,11 +78,8 @@ $grayish-red: #bfbfbf;
--color-accent-error: #{$red-400};
--color-background-error: #{$red-200};
--color-foreground-error: #{$red-500};
--color-foreground-success: #{color.scale($green-500, $lightness: 30%)};
--color-foreground-warning: #{color.scale($orange-500, $lightness: 10%)};
--color-accent-info: #{$blue-500};
--color-background-info: #{$blue-200};
--color-foreground-info: #{color.scale($blue-500, $lightness: 28%)};
--color-background-default: #{$white};
--color-accent-default: #{$gray-100};
--color-icon-default: #{$blue-teal-700};
@ -126,11 +123,8 @@ $grayish-red: #bfbfbf;
--color-accent-error: #{$red-400};
--color-background-error: #{$red-950};
--color-foreground-error: #{$red-500};
--color-foreground-success: #{color.scale($green-500, $lightness: 30%)};
--color-foreground-warning: #{color.scale($orange-500, $lightness: 10%)};
--color-accent-info: #{$blue-500};
--color-background-info: #{$blue-950};
--color-foreground-info: #{color.scale($blue-500, $lightness: 28%)};
--color-background-default: #{$gray-950};
--color-accent-default: #{$gray-800};
--color-icon-default: #{$grayish-blue-500};

View File

@ -0,0 +1,45 @@
;; 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 app.main.ui.ds.product.branch-chip
(:require-macros
[app.main.style :as stl])
(:require
[app.common.data :as d]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[rumext.v2 :as mf]))
(def ^:private schema:branch-chip
[:map
[:class {:optional true} [:maybe :string]]
[:label :string]
[:is-open {:optional true} [:maybe :boolean]]])
(mf/defc branch-chip*
"The button that names a branch and opens the branch switcher. It only
draws the trigger: the popover it opens belongs to the caller."
{::mf/schema schema:branch-chip}
[{:keys [class label is-open] :rest props}]
(let [is-open (d/nilv is-open false)
props (mf/spread-props props
{:class [class (stl/css-case :branch-chip true
:is-open is-open)]
:type "button"
:aria-haspopup "dialog"
:aria-expanded is-open})]
[:> :button props
[:> icon* {:icon-id i/git-branch
:size "s"
:class (stl/css :icon)}]
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :label)}
label]
[:> icon* {:icon-id i/arrow-down
:size "s"
:class (stl/css :icon)}]]))

View File

@ -0,0 +1,51 @@
{ /* 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 */ }
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as BranchChip from "./branch_chip.stories";
<Meta title="Product/Branching/BranchChip" />
# BranchChip
The button that names the branch the open file is and opens the branch
switcher.
<Canvas of={BranchChip.Default} />
## Variants
`is-open` draws the chip pressed while its popover is shown.
<Canvas of={BranchChip.Open} />
Long names are truncated with an ellipsis; the chip never grows past its
container.
<Canvas of={BranchChip.LongName} />
## Technical notes
The label is mandatory. Any other prop (`on-click`, `title`, `aria-*`)
goes to the underlying `button`, which already carries
`aria-haspopup="dialog"` and `aria-expanded`.
```clj
[:> branch-chip* {:label (:name branch)
:is-open open?
:on-click on-toggle}]
```
## Usage guidelines (design)
### Where to use
In the workspace header row, only when the file has branches.
### Interaction / Behavior
Clicking it opens or closes the branch switcher. The popover is the
caller's: the chip only draws the trigger.

View File

@ -0,0 +1,48 @@
// 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
@use "ds/_borders.scss" as *;
.branch-chip {
--chip-bg-color: var(--color-background-tertiary);
--chip-border-color: var(--color-background-quaternary);
display: flex;
align-items: center;
gap: var(--sp-xs);
min-inline-size: 0;
max-inline-size: 100%;
padding: var(--sp-xs) var(--sp-s);
border: $b-1 solid var(--chip-border-color);
border-radius: $br-8;
background-color: var(--chip-bg-color);
color: var(--color-foreground-primary);
cursor: pointer;
&:hover {
--chip-bg-color: var(--color-background-quaternary);
}
&:focus-visible {
outline: $b-1 solid var(--color-accent-primary);
}
&.is-open {
--chip-bg-color: var(--color-background-quaternary);
--chip-border-color: var(--color-accent-primary);
}
}
.icon {
flex-shrink: 0;
color: var(--color-foreground-secondary);
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,53 @@
// 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
import * as React from "react";
import Components from "@target/components";
const { BranchChip } = Components;
export default {
title: "Product/Branching/BranchChip",
component: BranchChip,
argTypes: {
label: {
control: { type: "text" },
},
isOpen: {
control: { type: "boolean" },
},
},
args: {
label: "feature-login",
isOpen: false,
},
render: ({ ...args }) => <BranchChip {...args} />,
};
export const Default = {};
export const Main = {
args: {
label: "Main",
},
};
export const Open = {
args: {
isOpen: true,
},
};
export const LongName = {
args: {
label: "redesign/checkout-flow-v2-with-a-very-long-name",
},
render: ({ ...args }) => (
<div style={{ inlineSize: "200px" }}>
<BranchChip {...args} />
</div>
),
};

View File

@ -0,0 +1,43 @@
;; 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 app.main.ui.ds.product.branch-pill
(:require-macros
[app.main.style :as stl])
(:require
[app.common.data :as d]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[rumext.v2 :as mf]))
(def ^:private schema:branch-pill
[:map
[:class {:optional true} [:maybe :string]]
[:label :string]
[:is-main {:optional true} [:maybe :boolean]]
[:is-review {:optional true} [:maybe :boolean]]])
(mf/defc branch-pill*
"A branch name inside running text, as a static pill. Main is drawn
muted; any other branch in the accent color, or in the info blue when
it is the branch of a pull request under review."
{::mf/schema schema:branch-pill}
[{:keys [class label is-main is-review] :rest props}]
(let [is-main (d/nilv is-main false)
is-review (d/nilv is-review false)
props (mf/spread-props props
{:class [class (stl/css-case :branch-pill true
:is-main is-main
:is-review (and is-review (not is-main)))]})]
[:> :span props
[:> icon* {:icon-id (if is-main i/git-commit-vertical i/git-branch)
:size "s"
:class (stl/css :icon)}]
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :label)}
label]]))

View File

@ -0,0 +1,53 @@
{ /* 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 */ }
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as BranchPill from "./branch_pill.stories";
<Meta title="Product/Branching/BranchPill" />
# BranchPill
A branch name inside running text, drawn as a static pill so it reads
apart from the words around it.
<Canvas of={BranchPill.Default} />
## Variants
`is-main` draws main muted, with the commit icon.
<Canvas of={BranchPill.Main} />
`is-review` draws the branch in the info blue that marks pull requests
and design reviews. It has no effect on main.
<Canvas of={BranchPill.Review} />
Two pills and an arrow name where a change goes.
<Canvas of={BranchPill.Route} />
## Technical notes
The label is mandatory. Any other prop goes to the underlying `span`.
```clj
[:> branch-pill* {:label (:branch-name pr) :is-review true}]
[:> branch-pill* {:label (tr "workspace.branches.main") :is-main true}]
```
## Usage guidelines (design)
### Where to use
Wherever a branch is named inside a line of text: the compare dialog
header, the pull request banner.
### Interaction / Behavior
None: it is a label, not a control. For a branch that opens the
switcher, use `BranchChip`.

View File

@ -0,0 +1,40 @@
// 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
@use "ds/_borders.scss" as *;
.branch-pill {
--pill-fg-color: var(--color-accent-primary);
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: var(--sp-xxs);
min-inline-size: 0;
max-inline-size: 100%;
padding: var(--sp-xxs) var(--sp-s);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
color: var(--pill-fg-color);
&.is-main {
--pill-fg-color: var(--color-foreground-secondary);
}
&.is-review {
--pill-fg-color: var(--color-accent-info);
}
}
.icon {
flex-shrink: 0;
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,57 @@
// 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
import * as React from "react";
import Components from "@target/components";
const { BranchPill } = Components;
export default {
title: "Product/Branching/BranchPill",
component: BranchPill,
argTypes: {
label: {
control: { type: "text" },
},
isMain: {
control: { type: "boolean" },
},
isReview: {
control: { type: "boolean" },
},
},
args: {
label: "feature-login",
isMain: false,
isReview: false,
},
render: ({ ...args }) => <BranchPill {...args} />,
};
export const Default = {};
export const Main = {
args: {
label: "Main",
isMain: true,
},
};
export const Review = {
args: {
isReview: true,
},
};
export const Route = {
render: () => (
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<BranchPill label="feature-login" isReview={true} />
<span>→</span>
<BranchPill label="Main" isMain={true} />
</div>
),
};

View File

@ -0,0 +1,46 @@
;; 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 app.main.ui.ds.product.branch-row
(:require-macros
[app.main.style :as stl])
(:require
[app.common.data :as d]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[rumext.v2 :as mf]))
(def ^:private schema:branch-row
[:map
[:class {:optional true} [:maybe :string]]
[:label :string]
[:is-current {:optional true} [:maybe :boolean]]])
(mf/defc branch-row*
"A branch in a short list, as a button that opens it. The current one is
marked and cannot be opened again. `children` go to the end of the row:
usually a `branch-status*`."
{::mf/schema schema:branch-row}
[{:keys [class label is-current children] :rest props}]
(let [is-current (d/nilv is-current false)
props (mf/spread-props props
{:class [class (stl/css-case :branch-row true
:is-current is-current)]
:type "button"
:disabled is-current
:aria-current (when is-current "true")})]
[:> :button props
[:> icon* {:icon-id (if is-current i/tick i/git-branch)
:size "s"
:class (stl/css :icon)}]
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :label)}
label]
(when (some? children)
[:span {:class (stl/css :aside)}
children])]))

View File

@ -0,0 +1,39 @@
{ /* 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 */ }
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as BranchRow from "./branch_row.stories";
<Meta title="Product/Branching/BranchRow" />
# BranchRow
A branch in a short list, such as the branch switcher or the dashboard
branches popover. Clicking it opens the branch.
<Canvas of={BranchRow.Default} />
## Variants
The branch the open file is shows a tick and cannot be opened again.
<Canvas of={BranchRow.Current} />
Children go to the end of the row, usually a `BranchStatus`.
<Canvas of={BranchRow.WithStatus} />
## Technical notes
The label is mandatory. The row is a `button`; any other prop
(`on-click`, `data-*`) goes to it.
```clj
[:> branch-row* {:label (:name branch)
:data-branch (dm/str (:branch-file-id branch))
:on-click on-open}
[:> branch-status* {:ahead (:ahead branch) :behind (:behind branch)}]]
```

View File

@ -0,0 +1,64 @@
// 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
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as *;
.branch-row {
--row-bg-color: transparent;
--row-icon-color: var(--color-foreground-secondary);
display: flex;
align-items: center;
gap: var(--sp-s);
inline-size: 100%;
min-block-size: $sz-32;
padding: var(--sp-xs) var(--sp-s);
border: none;
border-radius: $br-8;
background-color: var(--row-bg-color);
color: var(--color-foreground-primary);
text-align: start;
cursor: pointer;
&:hover:not(:disabled) {
--row-bg-color: var(--color-background-quaternary);
}
&:focus-visible {
outline: $b-1 solid var(--color-accent-primary);
}
&.is-current {
--row-icon-color: var(--color-accent-primary);
cursor: default;
}
}
.icon {
flex-shrink: 0;
color: var(--row-icon-color);
}
.label {
flex: 1;
min-inline-size: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.aside {
@include use-typography("body-small");
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--sp-s);
color: var(--color-foreground-secondary);
}

View File

@ -0,0 +1,51 @@
// 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
import * as React from "react";
import Components from "@target/components";
const { BranchRow, BranchStatus } = Components;
export default {
title: "Product/Branching/BranchRow",
component: BranchRow,
argTypes: {
label: {
control: { type: "text" },
},
isCurrent: {
control: { type: "boolean" },
},
},
args: {
label: "onboarding-v2",
isCurrent: false,
},
render: ({ ...args }) => (
<div style={{ inlineSize: "270px" }}>
<BranchRow {...args} />
</div>
),
};
export const Default = {};
export const Current = {
args: {
label: "feature-login",
isCurrent: true,
},
};
export const WithStatus = {
render: ({ ...args }) => (
<div style={{ inlineSize: "270px" }}>
<BranchRow {...args}>
<BranchStatus ahead={5} review="approved" approvals={2} reviewers={2} />
</BranchRow>
</div>
),
};

View File

@ -0,0 +1,79 @@
;; 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 app.main.ui.ds.product.branch-status
(:require-macros
[app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(def ^:private schema:branch-status
[:map
[:class {:optional true} [:maybe :string]]
[:ahead {:optional true} [:maybe :int]]
[:behind {:optional true} [:maybe :int]]
[:has-error {:optional true} [:maybe :boolean]]
[:conflicts {:optional true} [:maybe :int]]
[:review {:optional true} [:maybe [:enum "pending" "approved"]]]
[:approvals {:optional true} [:maybe :int]]
[:reviewers {:optional true} [:maybe :int]]])
(mf/defc branch-status*
"How a branch stands against main: the changes it has and lacks, and one
pill. Conflicts take the pill over the review state, because they block
the merge whatever the reviewers say. A comparison that failed shows a
warning instead of the counts, since zeros would read as in sync."
{::mf/schema schema:branch-status}
[{:keys [class ahead behind has-error conflicts review approvals reviewers] :rest props}]
(let [ahead (d/nilv ahead 0)
behind (d/nilv behind 0)
conflicts (d/nilv conflicts 0)
approvals (d/nilv approvals 0)
reviewers (d/nilv reviewers 0)
props (mf/spread-props props
{:class [class (stl/css :branch-status)]})]
[:> :div props
(if ^boolean has-error
[:span {:class (stl/css :error)
:title (tr "workspace.branches.counts.error")}
[:> icon* {:icon-id i/triangle-alert :size "s"}]]
[:*
(when (pos? ahead)
[:span {:class (stl/css :count)
:title (tr "workspace.branches.counts.ahead")}
[:> icon* {:icon-id i/arrow-up :size "s"}]
(dm/str ahead)])
(when (pos? behind)
[:span {:class (stl/css :count :count-behind)
:title (tr "workspace.branches.counts.behind")}
[:> icon* {:icon-id i/arrow-down :size "s"}]
(dm/str behind)])])
(cond
(pos? conflicts)
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :pill :pill-conflict)
:title (tr "workspace.branches.banner.conflicts" (dm/str conflicts))}
[:> icon* {:icon-id i/triangle-alert :size "s"}]
(dm/str conflicts)]
(some? review)
[:> text* {:as "span"
:typography t/body-small
:class (stl/css-case :pill true
:pill-pending (= review "pending")
:pill-approved (= review "approved"))
:title (tr "workspace.pull-requests.info.approvals")}
[:> icon* {:icon-id i/git-pull-request-arrow :size "s"}]
(when (pos? reviewers)
(dm/str approvals "/" reviewers))])]))

View File

@ -0,0 +1,56 @@
{ /* 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 */ }
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as BranchStatus from "./branch_status.stories";
<Meta title="Product/Branching/BranchStatus" />
# BranchStatus
How a branch stands against main: the changes it has (↑) and lacks (↓),
and one pill with what blocks or moves its merge.
<Canvas of={BranchStatus.Default} />
## Variants
The pill shows the review state of an open pull request, pending or
approved, with its approvals.
<Canvas of={BranchStatus.InReview} />
<Canvas of={BranchStatus.Approved} />
Conflicts take the pill over the review state, because they block the
merge whatever the reviewers say.
<Canvas of={BranchStatus.WithConflicts} />
When the comparison with main fails, a warning replaces the counts:
zeros would read as "in sync".
<Canvas of={BranchStatus.ComparisonFailed} />
## Technical notes
Every prop is optional. Counts at zero are not drawn.
```clj
[:> branch-status* {:ahead (:ahead branch)
:behind (:behind branch)
:conflicts (:conflicts branch)
:review "pending"
:approvals 1
:reviewers 2}]
```
## Usage guidelines (design)
### Where to use
Next to a branch name: the workspace header row, the branch switcher and
the branch lists.

View File

@ -0,0 +1,64 @@
// 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
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as *;
.branch-status {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--sp-s);
}
// counts use the DS code font, as every branch figure does; they
// only show when there are changes, so they stand out
.count {
@include use-typography("code-font");
display: flex;
align-items: center;
gap: var(--sp-xxs);
color: var(--color-accent-primary);
}
// what main has and the branch lacks reads in the error color
.count-behind {
color: var(--color-foreground-error);
}
.error {
display: flex;
color: var(--color-accent-warning);
}
.pill {
--pill-fg-color: var(--color-foreground-secondary);
--pill-bg-color: var(--color-background-tertiary);
display: flex;
align-items: center;
gap: var(--sp-xxs);
padding: 0 var(--sp-xs);
border-radius: $br-4;
color: var(--pill-fg-color);
background-color: var(--pill-bg-color);
}
.pill-conflict {
--pill-fg-color: var(--color-accent-warning);
--pill-bg-color: var(--color-background-warning);
}
.pill-pending {
--pill-fg-color: var(--color-accent-info);
--pill-bg-color: var(--color-background-info);
}
.pill-approved {
--pill-fg-color: var(--color-accent-success);
--pill-bg-color: var(--color-background-success);
}

View File

@ -0,0 +1,82 @@
// 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
import * as React from "react";
import Components from "@target/components";
const { BranchStatus } = Components;
export default {
title: "Product/Branching/BranchStatus",
component: BranchStatus,
argTypes: {
ahead: {
control: { type: "number", min: 0 },
},
behind: {
control: { type: "number", min: 0 },
},
hasError: {
control: { type: "boolean" },
},
conflicts: {
control: { type: "number", min: 0 },
},
review: {
control: "select",
options: [undefined, "pending", "approved"],
},
approvals: {
control: { type: "number", min: 0 },
},
reviewers: {
control: { type: "number", min: 0 },
},
},
args: {
ahead: 3,
behind: 1,
hasError: false,
conflicts: 0,
},
render: ({ ...args }) => <BranchStatus {...args} />,
};
export const Default = {};
export const InReview = {
args: {
behind: 0,
review: "pending",
approvals: 1,
reviewers: 2,
},
};
export const Approved = {
args: {
behind: 0,
review: "approved",
approvals: 2,
reviewers: 2,
},
};
export const WithConflicts = {
args: {
behind: 4,
conflicts: 2,
review: "pending",
approvals: 1,
reviewers: 2,
},
};
export const ComparisonFailed = {
args: {
hasError: true,
},
};

View File

@ -0,0 +1,309 @@
;; 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.branch-switcher
"The branch row under the workspace left header: a chip naming the
branch the open file is, its status next to it, and a popover to switch
branch, create one or reach the full list. It only exists once the file
has branches; before that the first branch comes from the main menu."
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[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.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.ds.controls.input :refer [input*]]
[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.ds.product.branch-chip :refer [branch-chip*]]
[app.main.ui.ds.product.branch-row :refer [branch-row*]]
[app.main.ui.ds.product.branch-status :refer [branch-status*]]
[app.main.ui.workspace.menu-shortcuts :refer [shortcuts*]]
[app.main.ui.workspace.sidebar.pull-requests :as prui]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def has-branches
"True when the open file is a branch or has branches; the branch row and
its chip only show then."
(l/derived dwb/has-branches? st/state))
(def ^:private branches
(l/derived :workspace-branches st/state))
(def ^:private switcher-open
(l/derived #(boolean (:workspace-branch-switcher %)) st/state))
(defn- open-pr-by-branch
"The open pull requests indexed by the id of their branch."
[prs]
(->> (:data prs)
(filter #(= "open" (:status %)))
(map (juxt :file-branch-id identity))
(into {})))
(mf/defc row-status*
"The `branch-status*` of a branch row and its open pull request (nil
when it has none)."
{::mf/private true}
[{:keys [row pr]}]
(let [[approvals reviewers] (if pr (prui/current-approvals pr) [0 0])
review (when (some? pr)
(if (and (pos? reviewers) (= approvals reviewers)) "approved" "pending"))]
[:> branch-status* {:ahead (:ahead row)
:behind (:behind row)
:has-error (boolean (or (:diff-error row) (nil? (:ahead row))))
:conflicts (:conflicts row)
:review review
:approvals approvals
:reviewers reviewers}]))
(defn- in-sync?
"True when a branch has nothing to report against main: no changes on
either side, no conflicts, no open review and a comparison that worked."
[row pr]
(and (nil? pr)
(not (:diff-error row))
(some? (:ahead row))
(zero? (:ahead row))
(zero? (or (:behind row) 0))
(zero? (or (:conflicts row) 0))))
;; --- Popover
(mf/defc switcher-row*
{::mf/private true}
[{:keys [row pr current on-open]}]
[:li
[:> branch-row* {:label (:name row)
:is-current (boolean current)
:data-branch (dm/str (:branch-file-id row))
:on-click on-open}
(if (:is-main row)
(tr "workspace.branches.main-subtitle")
[:> row-status* {:row row :pr pr}])]])
(mf/defc switcher-popover*
{::mf/private true}
[{:keys [file current prs-by-branch on-close]}]
(let [{:keys [data]} (mf/deref branches)
filter* (mf/use-state "")
filter-v (deref filter*)
archived* (mf/use-state false)
show-archived? (deref archived*)
matches?
(fn [row]
(or (str/blank? filter-v)
(str/includes? (str/lower (or (:name row) "")) (str/lower filter-v))))
;; on a branch, main is a switch target too and goes first
main-row
(when-not (:is-main current)
{:name (tr "workspace.branches.main")
:is-main true
:branch-file-id (:source-file-id current)})
rows
(mf/with-memo [data current]
(remove #(= (:id %) (:id current)) data))
others
(cond->> (filter #(= "open" (:status %)) rows)
main-row (cons main-row)
:always (filter matches?))
archived
(->> rows
(remove #(= "open" (:status %)))
(filter matches?))
on-filter-change
(mf/use-fn #(reset! filter* (dom/get-target-val %)))
on-toggle-archived
(mf/use-fn #(swap! archived* not))
on-open
(mf/use-fn
(fn [event]
(let [id (-> event dom/get-current-target (dom/get-data "branch") uuid/parse)]
(st/emit! (dwb/open-branch id)))))
on-create
(mf/use-fn
(mf/deps file on-close)
(fn [_]
(on-close)
(modal/show! :create-branch {:file-name (:name file)})))
on-show-all
(mf/use-fn
(mf/deps on-close)
(fn [_]
(on-close)
(modal/show! :branches-list {})))]
[:div {:class (stl/css :popover)
:role "dialog"
:aria-label (tr "workspace.branches.menu")}
[:> input* {:variant "dense"
:icon i/search
:auto-focus true
:placeholder (tr "workspace.branches.search.placeholder")
:value filter-v
:on-change on-filter-change}]
[:div {:class (stl/css :section)} (tr "workspace.branches.section.current")]
[:ul {:class (stl/css :rows)}
[:> switcher-row* {:row current
:pr (get prs-by-branch (:id current))
:current true}]]
(when (seq others)
[:*
[:div {:class (stl/css :section)}
[:span (tr "workspace.branches.section.others")]
[:span (dm/str (count others))]]
[:ul {:class (stl/css :rows)}
(for [row others]
[:> switcher-row* {:key (dm/str (:branch-file-id row))
:row row
:pr (get prs-by-branch (:id row))
:on-open on-open}])]])
(when (seq archived)
[:*
[:button {:class (stl/css :action)
:aria-expanded show-archived?
:on-click on-toggle-archived}
[:> i/icon* {:icon-id (if show-archived? i/arrow-down i/arrow-right) :size "s"}]
[:span {:class (stl/css :action-label)} (tr "workspace.branches.archived")]
[:span {:class (stl/css :action-aside)} (dm/str (count archived))]]
(when show-archived?
[:ul {:class (stl/css :rows)}
(for [row archived]
[:> switcher-row* {:key (dm/str (:branch-file-id row))
:row row
:on-open on-open}])])])
[:hr {:class (stl/css :divider)}]
;; branches only grow from main: on a branch there is nothing to create
(when (:is-main current)
[:button {:class (stl/css :action) :on-click on-create}
[:> i/icon* {:icon-id i/git-branch-plus :size "s"}]
[:span {:class (stl/css :action-label)} (tr "workspace.branches.switcher.new")]])
[:button {:class (stl/css :action) :on-click on-show-all}
[:> i/icon* {:icon-id i/menu :size "s"}]
[:span {:class (stl/css :action-label)} (tr "workspace.branches.switcher.all")]
[:> shortcuts* {:id :open-branches}]]]))
;; --- Branch row under the header
(mf/defc branch-bar*
[{:keys [file]}]
(let [file-id (:id file)
visible? (mf/deref has-branches)
open? (mf/deref switcher-open)
ctx (mf/deref refs/branch-context)
profile (mf/deref refs/profile)
prs (mf/deref refs/pull-requests)
prs-by-branch
(mf/with-memo [prs]
(when (dwpr/enabled?)
(open-pr-by-branch prs)))
;; what the open file is: its branch row, or main
current
(or ctx
{:name (tr "workspace.branches.main")
:is-main true
:created-by (:id profile)
:branch-file-id file-id})
merged? (contains? #{"merged" "archived"} (:status ctx))
;; clicks inside the row (chip and popover) must not close it
bar-ref (mf/use-ref nil)
on-toggle
(mf/use-fn
(fn [event]
;; the dropdown closes itself on any outside click: the click
;; that opens it must not reach it
(dom/stop-propagation event)
(st/emit! (dwb/toggle-branch-switcher))))
on-close
(mf/use-fn #(st/emit! (dwb/toggle-branch-switcher false)))]
;; the list decides whether the row shows at all, so it loads with the
;; file, and so does the branch context: during a review the bottom
;; banner that also loads it is replaced by the review banner
(mf/with-effect [file-id]
(when (contains? cf/flags :branching)
(st/emit! (dwb/init-branches-state)
(dwb/fetch-branch-context)
(dwb/toggle-branch-switcher false))))
;; the row makes the header taller; panels that size themselves against
;; the viewport read this to keep their bottom edge in place
(mf/with-effect [visible?]
(let [root (dom/get-root)]
(if visible?
(dom/set-css-property! root "--branch-bar-block-size" "var(--branch-bar-size)")
(dom/unset-css-property! root "--branch-bar-block-size"))
(fn [] (dom/unset-css-property! root "--branch-bar-block-size"))))
(when ^boolean visible?
[:div {:class (stl/css :branch-bar) :ref bar-ref}
[:> branch-chip* {:label (:name current)
:is-open open?
:title (if (:is-main current)
(tr "workspace.branches.switcher.tooltip")
(tr "workspace.branches.header-badge-tooltip" (:name current)))
:on-click on-toggle}]
;; the right side never stays empty: a plain note says where the
;; file stands until there are changes to count
(let [pr (get prs-by-branch (:id ctx))]
(cond
(nil? ctx)
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :status-note)}
(tr "workspace.branches.main-subtitle")]
^boolean merged?
nil
(in-sync? ctx pr)
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :status-note)}
(tr "workspace.branches.banner.status.up-to-date")]
:else
[:> row-status* {:row ctx :pr pr}]))
[:& dropdown {:show open? :on-close on-close :container bar-ref}
[:> switcher-popover* {:file file
:current current
:prs-by-branch prs-by-branch
:on-close on-close}]]])))

View File

@ -0,0 +1,121 @@
// 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/typography.scss" as *;
:global(:root) {
--branch-bar-size: #{$sz-36};
}
// --- Branch row under the header
.branch-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-s);
block-size: var(--branch-bar-size);
padding: var(--sp-xs) var(--sp-m) var(--sp-s);
}
.status-note {
flex-shrink: 0;
color: var(--color-foreground-secondary);
}
// --- Popover
.popover {
position: absolute;
inset-block-start: 100%;
inset-inline-start: var(--sp-s);
z-index: var(--z-index-dropdown);
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
inline-size: $sz-284;
max-block-size: $sz-480;
overflow-y: auto;
padding: var(--sp-s);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
box-shadow: var(--el-shadow-dark);
}
.section {
@include use-typography("headline-small");
display: flex;
justify-content: space-between;
padding: var(--sp-s) var(--sp-s) var(--sp-xs);
color: var(--color-foreground-secondary);
}
.rows {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
}
.action {
@include use-typography("body-small");
display: flex;
align-items: center;
gap: var(--sp-s);
inline-size: 100%;
min-block-size: $sz-32;
padding: var(--sp-xs) var(--sp-s);
border: none;
border-radius: $br-8;
background: none;
color: var(--color-foreground-primary);
text-align: start;
cursor: pointer;
&:hover:not(:disabled) {
--shortcut-foreground-color: var(--color-foreground-primary);
background-color: var(--color-background-quaternary);
}
&:focus-visible {
outline: $b-1 solid var(--color-accent-primary);
}
&:disabled {
cursor: default;
}
}
.action-label {
flex: 1;
min-inline-size: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.action-aside {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--sp-s);
color: var(--color-foreground-secondary);
}
.divider {
inline-size: 100%;
margin: var(--sp-xs) 0;
border: none;
border-block-start: $b-1 solid var(--color-background-quaternary);
}

View File

@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.modal :as modal]
[app.main.data.workspace :as dw]
@ -18,6 +19,7 @@
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.workspace.branch-switcher :refer [branch-bar*]]
[app.main.ui.workspace.main-menu :as main-menu]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
@ -36,8 +38,8 @@
;; When the open file is a branch, `branch-ctx` is non-nil (the
;; main file is never a branch, so this is also how we tell them
;; apart). On a branch we show "<main name> (<branch name>)" and a
;; badge; on main we keep the plain file name.
;; apart). On a branch the title is main's name: the branch itself
;; is named by the chip in the branch row below.
branch-ctx (mf/deref refs/branch-context)
branch? (and (some? branch-ctx) (some? (:source-name branch-ctx)))
@ -54,7 +56,7 @@
(tr "workspace.pull-requests.header-title" pr-title)
^boolean branch?
(dm/str (:source-name branch-ctx) " (" (:name branch-ctx) ")")
(:source-name branch-ctx)
:else
file-name)
@ -116,67 +118,69 @@
(when ^boolean editing?
(dom/select-text! (mf/ref-val input-ref))))
[:header {:class (dm/str class " " (stl/css :workspace-header-left))}
[:a {:on-click go-back
:class (stl/css :main-icon)} deprecated-icon/logo-icon]
[:div {:alt (tr "workspace.sitemap")
:class (stl/css :project-tree)}
[:div
{:class (stl/css :project-name)
:on-click nav-to-project}
(:name project)]
(if ^boolean editing?
[:input
{:class (stl/css :file-name-input)
:type "text"
:ref input-ref
:on-blur handle-blur
:on-key-down handle-name-keydown
:auto-focus true
:default-value (:name file "")}]
[:div
{:class (stl/css :file-name)
:title display-name
;; on a branch the name is fixed ("File (Branch)"); renaming here
;; would be confusing, so double-click editing is disabled.
:on-double-click (when-not ^boolean branch? start-editing-name)}
;; Persistence state widget
[:div {:class (case persistence-status
:pending (stl/css :status-notification :pending-status)
:saving (stl/css :status-notification :saving-status)
:saved (stl/css :status-notification :saved-status)
:retrying (stl/css :status-notification :retrying-status)
:error (stl/css :status-notification :error-status)
(stl/css :status-notification))
:title (case persistence-status
:pending (tr "workspace.header.saving")
:saving (tr "workspace.header.saving")
:saved (tr "workspace.header.saved")
:retrying (tr "workspace.header.retrying")
:error (tr "workspace.header.save-error")
nil)}
(case persistence-status
:pending deprecated-icon/status-alert
:saving deprecated-icon/status-alert
:saved deprecated-icon/status-tick
:retrying deprecated-icon/status-alert
:error deprecated-icon/status-wrong
nil)]
(cond
^boolean review?
[:> tooltip* {:content (tr "workspace.pull-requests.header-badge-tooltip" pr-title)
:placement "bottom"}
[:span {:class (stl/css :review-badge)}
[:> icon* {:icon-id i/git-pull-request-arrow :size "s"}]]]
^boolean branch?
[:> tooltip* {:content (tr "workspace.branches.header-badge-tooltip" (:name branch-ctx))
:placement "bottom"}
[:span {:class (stl/css :branch-badge)}
[:> icon* {:icon-id i/git-branch :size "s"}]]])
[:div {:class (stl/css :file-name-label)} display-name]])]
(when ^boolean shared?
[:span {:class (stl/css :shared-badge)} deprecated-icon/library])
[:div {:class (stl/css :menu-section)}
[:> main-menu/menu* {:layout layout
:file file}]]]))
[:div {:class (dm/str class " " (stl/css :workspace-header-left-wrapper))}
[:header {:class (stl/css :workspace-header-left)}
[:a {:on-click go-back
:class (stl/css :main-icon)} deprecated-icon/logo-icon]
[:div {:alt (tr "workspace.sitemap")
:class (stl/css :project-tree)}
[:div
{:class (stl/css :project-name)
:on-click nav-to-project}
(:name project)]
(if ^boolean editing?
[:input
{:class (stl/css :file-name-input)
:type "text"
:ref input-ref
:on-blur handle-blur
:on-key-down handle-name-keydown
:auto-focus true
:default-value (:name file "")}]
[:div
{:class (stl/css :file-name)
:title display-name
;; on a branch the title is main's name, which this file does not
;; own, so double-click editing is disabled.
:on-double-click (when-not ^boolean branch? start-editing-name)}
;; Persistence state widget
[:div {:class (case persistence-status
:pending (stl/css :status-notification :pending-status)
:saving (stl/css :status-notification :saving-status)
:saved (stl/css :status-notification :saved-status)
:retrying (stl/css :status-notification :retrying-status)
:error (stl/css :status-notification :error-status)
(stl/css :status-notification))
:title (case persistence-status
:pending (tr "workspace.header.saving")
:saving (tr "workspace.header.saving")
:saved (tr "workspace.header.saved")
:retrying (tr "workspace.header.retrying")
:error (tr "workspace.header.save-error")
nil)}
(case persistence-status
:pending deprecated-icon/status-alert
:saving deprecated-icon/status-alert
:saved deprecated-icon/status-tick
:retrying deprecated-icon/status-alert
:error deprecated-icon/status-wrong
nil)]
(when ^boolean review?
[:> tooltip* {:content (tr "workspace.pull-requests.header-badge-tooltip" pr-title)
:placement "bottom"}
[:span {:class (stl/css :review-badge)}
[:> icon* {:icon-id i/git-pull-request-arrow :size "s"}]]])
[:div {:class (stl/css :file-name-label)}
(if ^boolean review?
[:*
[:span {:class (stl/css :review-label)}
(tr "workspace.pull-requests.header-label")]
" " pr-title]
display-name)]])]
(when ^boolean shared?
[:span {:class (stl/css :shared-badge)} deprecated-icon/library])
[:div {:class (stl/css :menu-section)}
[:> main-menu/menu* {:layout layout
:file file}]]]
(when (contains? cf/flags :branching)
[:> branch-bar* {:file file}])]))

View File

@ -4,8 +4,17 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "refactor/common-refactor.scss" as deprecated;
.workspace-header-left-wrapper {
display: flex;
flex-direction: column;
min-inline-size: 0;
}
.workspace-header-left {
display: flex;
align-items: center;
@ -82,30 +91,23 @@
}
}
.branch-badge {
@include deprecated.flex-center;
// pull request review: info blue, as every review mark (the banner, the
// review pill), so an active review stands out from the plain branch badge
.review-badge {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: deprecated.$s-16;
height: deprecated.$s-16;
margin-right: deprecated.$s-6;
border-radius: deprecated.$br-4;
background-color: var(--button-secondary-background-color-rest);
color: var(--button-secondary-foreground-color-rest);
inline-size: $sz-16;
block-size: $sz-16;
margin-inline-end: px2rem(6);
border-radius: $br-4;
background-color: var(--color-background-info);
color: var(--color-accent-info);
}
// pull request review: accent-colored so an active review stands out from
// the plain branch badge (the file is still the live, editable branch)
.review-badge {
@include deprecated.flex-center;
flex-shrink: 0;
width: deprecated.$s-16;
height: deprecated.$s-16;
margin-right: deprecated.$s-6;
border-radius: deprecated.$br-4;
background-color: var(--button-primary-background-color-rest);
color: var(--button-primary-foreground-color-rest);
.review-label {
color: var(--color-accent-info);
}
.shared-badge {

View File

@ -20,13 +20,11 @@
[app.main.data.notifications :as ntf]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.shortcuts :as scd]
[app.main.data.workspace :as dw]
[app.main.data.workspace.branches :as dwb]
[app.main.data.workspace.comments :as dwcm]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.mcp :as mcp]
[app.main.data.workspace.shortcuts :as sc]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.versions :as dwv]
[app.main.features :as features]
@ -41,6 +39,8 @@
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.hooks.resize :as r]
[app.main.ui.workspace.branch-switcher :as bsw]
[app.main.ui.workspace.menu-shortcuts :refer [shortcuts*]]
[app.plugins.register :as preg]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
@ -49,19 +49,6 @@
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(mf/defc shortcuts*
{::mf/private true}
[{:keys [id]}]
(let [custom-shortcuts (mf/deref refs/custom-shortcuts)
customized? (let [c (get-in custom-shortcuts [:workspace id])]
(and c (not= c "")))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-effective-tooltip id custom-shortcuts))]
[:span {:class (stl/css-case :shortcut-key true
:customized-key customized?)
:key sc}
sc])]))
(mf/defc help-info-menu*
{::mf/private true
::mf/wrap [mf/memo]}
@ -629,18 +616,6 @@
(when (kbd/enter? event)
(on-show-version-history event))))
on-show-branches
(mf/use-fn
(fn [_]
(st/emit! (dwb/show-branches-panel))))
on-show-branches-key-down
(mf/use-fn
(mf/deps on-show-branches)
(fn [event]
(when (kbd/enter? event)
(on-show-branches event))))
on-pin-version
(mf/use-fn
(fn [_]
@ -731,15 +706,6 @@
(tr "dashboard.show-version-history")]
[:> shortcuts* {:id :toggle-history}]]
(when (contains? cf/flags :branching)
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item)
:on-click on-show-branches
:on-key-down on-show-branches-key-down
:id "file-menu-show-branches"}
[:span {:class (stl/css :item-name)}
(tr "workspace.branches.menu")]
[:> shortcuts* {:id :open-branches}]])
[:div {:class (stl/css :separator)}]])
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item)
@ -913,6 +879,7 @@
selected-sub-menu* (mf/use-state nil)
selected-sub-menu (deref selected-sub-menu*)
mcp (mf/deref refs/mcp)
has-branches? (mf/deref bsw/has-branches)
toggle-menu
(mf/use-fn
@ -1001,6 +968,16 @@
::ev/origin "workspace:menu"})
(modal/show :plugin-management {}))))
;; the switcher when the file has branches; the create dialog when
;; it has none, since the switcher only exists once a branch does
show-branches
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! show-menu* false)
(reset! selected-sub-menu* nil)
(st/emit! (dwb/show-branches))))
show-shortcuts
(mf/use-fn
(mf/deps layout)
@ -1145,6 +1122,22 @@
[:> icon* {:icon-id i/arrow-right
:class (stl/css :item-arrow)}]]
(when (contains? cf/flags :branching)
[:*
[:div {:class (stl/css :separator)}]
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item :shortcut-item)
:on-click show-branches
:on-key-down (fn [event]
(when (kbd/enter? event)
(show-branches event)))
:on-pointer-enter close-sub-menu
:id "file-menu-branches"}
[:span {:class (stl/css :item-name)}
(if ^boolean has-branches?
(tr "workspace.branches.menu")
(tr "workspace.branches.menu.create"))]
[:> shortcuts* {:id :open-branches}]]])
(when (or (and (contains? cf/flags :subscriptions)
(not= "enterprise" subscription-type))
(contains? cf/flags :admin-console))

View File

@ -4,7 +4,7 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/typography.scss" as t;
@use "ds/typography.scss" as *;
@use "ds/z-index.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@ -27,11 +27,14 @@
.menu {
top: $sz-48;
left: calc(var(--left-sidebar-width) - px2rem(44));
inline-size: $sz-192;
// wide enough for a top-level entry with its shortcut keys
inline-size: $sz-224;
}
// submenus open right after the menu's edge
.sub-menu {
left: calc(var(--left-sidebar-width) + px2rem(150));
left: calc(var(--left-sidebar-width) + px2rem(182));
min-width: $sz-364;
&.pos-1 {
@ -78,7 +81,7 @@
}
.base-menu-item {
@include t.use-typography("body-small");
@include use-typography("body-small");
display: grid;
align-items: center;
@ -94,7 +97,7 @@
&:hover {
--menu-foreground-color: var(--menu-foreground-color-hover);
--menu-background-color: var(--menu-background-color-hover);
--menu-shortcut-foreground-color: var(--menu-shortcut-foreground-color-hover);
--shortcut-foreground-color: var(--color-foreground-primary);
--menu-icon-foreground-color: var(--menu-foreground-color-hover);
}
@ -122,6 +125,18 @@
grid-area: name;
}
// a top-level entry with a shortcut instead of a submenu arrow: the keys
// keep their size and the name gives way
.shortcut-item {
gap: var(--sp-s);
.item-name {
@include text-ellipsis;
min-inline-size: 0;
}
}
.plugin-name {
@include text-ellipsis;
@ -169,28 +184,3 @@
left: calc(-1 * var(--sp-xs));
margin-top: var(--sp-s);
}
.shortcut {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-xxs);
color: var(--menu-shortcut-foreground-color);
}
.shortcut-key {
@include t.use-typography("body-small");
display: flex;
align-items: center;
justify-content: center;
height: px2rem(20);
padding: var(--sp-xxs) px2rem(6);
border-radius: $br-6;
background-color: var(--menu-shortcut-background-color);
}
.customized-key {
background-color: var(--color-background-info);
color: var(--color-foreground-primary);
}

View File

@ -0,0 +1,27 @@
;; 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 app.main.ui.workspace.menu-shortcuts
"The keys of a workspace shortcut, drawn at the end of a menu entry. A
shortcut the user customized is tinted."
(:require-macros [app.main.style :as stl])
(:require
[app.main.data.shortcuts :as scd]
[app.main.data.workspace.shortcuts :as sc]
[app.main.refs :as refs]
[rumext.v2 :as mf]))
(mf/defc shortcuts*
[{:keys [id]}]
(let [custom-shortcuts (mf/deref refs/custom-shortcuts)
customized? (let [c (get-in custom-shortcuts [:workspace id])]
(and c (not= c "")))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-effective-tooltip id custom-shortcuts))]
[:span {:class (stl/css-case :shortcut-key true
:customized-key customized?)
:key sc}
sc])]))

View File

@ -0,0 +1,37 @@
// 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
@use "ds/typography.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/_utils.scss" as *;
// The menu entry that holds the keys sets `--shortcut-foreground-color`
// on hover so the keys follow its text color.
.shortcut {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
gap: var(--sp-xxs);
color: var(--shortcut-foreground-color, var(--color-foreground-secondary));
}
.shortcut-key {
@include use-typography("body-small");
display: flex;
align-items: center;
justify-content: center;
block-size: px2rem(20);
padding: var(--sp-xxs) px2rem(6);
border-radius: $br-6;
background-color: var(--color-background-primary);
}
.customized-key {
background-color: var(--color-background-info);
color: var(--color-foreground-primary);
}

View File

@ -20,7 +20,6 @@
[app.main.data.style-dictionary :as sd]
[app.main.data.tokenscript :as ts]
[app.main.data.workspace :as dw]
[app.main.data.workspace.branches :as dwb]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@ -34,7 +33,6 @@
[app.main.ui.workspace.left-header :refer [left-header*]]
[app.main.ui.workspace.right-header :refer [right-header*]]
[app.main.ui.workspace.sidebar.assets :refer [assets-toolbox*]]
[app.main.ui.workspace.sidebar.branches :refer [branches-toolbox*]]
[app.main.ui.workspace.sidebar.debug :refer [debug-panel*]]
[app.main.ui.workspace.sidebar.history :refer [history-toolbox*]]
[app.main.ui.workspace.sidebar.layers :refer [layers-toolbox*]]
@ -244,7 +242,7 @@
::mf/memo true}
[]
(let [selected*
(hooks/use-shared-state dwb/history-sidebar-tab-key "history")
(hooks/use-persisted-state ::history-sidebar "history")
selected
(deref selected*)
@ -255,13 +253,10 @@
tabs
(mf/with-memo []
(cond-> [{:label (tr "workspace.versions.tab.history")
:id "history"}
{:label (tr "workspace.versions.tab.actions")
:id "actions"}]
(contains? cf/flags :branching)
(conj {:label (tr "workspace.branches.tab")
:id "branches"})))
[{:label (tr "workspace.versions.tab.history")
:id "history"}
{:label (tr "workspace.versions.tab.actions")
:id "actions"}])
button
(mf/with-memo []
@ -285,11 +280,7 @@
"actions"
[:article {:class (stl/css :versions-tab)}
[:> history-toolbox*]]
"branches"
[:article {:class (stl/css :history-tab)}
[:> branches-toolbox* {}]])]))
[:> history-toolbox*]])]))
(mf/defc right-sidebar*
[{:keys [layout section file-id page-id drawing-tool active-tokens] :as props}]

View File

@ -103,7 +103,7 @@
.left-sidebar {
display: grid;
grid-template: "header header" px2rem(52) "content resize" 1fr / 1fr 0;
grid-template: "header header" auto "content resize" 1fr / 1fr 0;
position: relative;
grid-area: left-sidebar;
min-inline-size: var(--left-sidebar-width);

View File

@ -11,7 +11,7 @@
grid-auto-rows: max-content;
// TODO: ugly hack :( Fix this! we shouldn't be hardcoding this height
height: calc(100vh - deprecated.$s-92);
height: calc(100vh - deprecated.$s-92 - var(--branch-bar-block-size, 0px));
scrollbar-gutter: stable;
overflow-y: auto;
padding-top: deprecated.$s-8;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -21,14 +21,18 @@
[app.main.data.workspace.pull-requests :as dwpr]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
dropdown-menu-item*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.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.menu :refer [menu* menu-item*]]
[app.main.ui.ds.layout.modal :refer [modal* modal-header* modal-content* modal-footer*]]
[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.util.dom :as dom]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
@ -93,7 +97,7 @@
proposed changes can be inspected. Like merging, it diffs the LIVE
branch against main."
[pr]
(modal/show! :branch-compare {:branch (pr->branch pr)}))
(modal/show! :branch-compare {:branch (pr->branch pr) :is-review true}))
(defn confirm-merge!
"Open the branching merge dialog for the pull request's branch:
@ -116,6 +120,40 @@
:accept-style :danger
:on-accept (fn [_] (st/emit! (dwpr/close-pull-request (:id pr))))})))
;; --- Dialog header
(mf/defc dialog-header*
"The header the branch and pull request dialogs share: an icon tile,
the title and a subtitle (a plain string, or any element), then
whatever the dialog adds after them, such as a status chip. The close
button belongs to `modal*`."
[{:keys [icon tone title subtitle children]}]
[:> modal-header* {:class (stl/css :dialog-header)}
(when icon
[:div {:class (stl/css-case :dialog-header-icon true
:is-warning (= tone :warning))}
[:> i/icon* {:icon-id icon}]])
[:div {:class (stl/css :dialog-header-text)}
[:> heading* {:level 2
:typography t/headline-medium
:class (stl/css :dialog-title)}
title]
(if (string? subtitle)
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :dialog-subtitle)}
subtitle]
subtitle)]
children])
(defn on-dismiss
"The `on-open-change` of a dialog that `modal*` closes (Escape, the
backdrop or its close button): run `on-close` when it closes."
[on-close]
(fn [open?]
(when-not open?
(on-close))))
;; --- Create pull request dialog (modal)
(mf/defc create-pull-request-dialog*
@ -169,65 +207,58 @@
(mf/with-effect []
(st/emit! (dtm/fetch-members)))
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:div {:class (stl/css :modal-header-icon)}
[:> i/icon* {:icon-id i/git-pull-request-arrow}]]
[:div {:class (stl/css :modal-header-text)}
[:h2 {:class (stl/css :modal-title)} (tr "workspace.pull-requests.create.title")]
[:span {:class (stl/css :modal-subtitle)}
(tr "workspace.pull-requests.create.subtitle" (or (:name branch) ""))]]
[:> button* {:variant "ghost"
:icon i/close
:aria-label (tr "labels.close")
:on-click on-close}]]
[:> modal* {:is-open true
:on-open-change (on-dismiss on-close)
:size "large"}
[:> dialog-header* {:icon i/git-pull-request-arrow
:title (tr "workspace.pull-requests.create.title")
:subtitle (tr "workspace.pull-requests.create.subtitle" (or (:name branch) ""))}]
[:div {:class (stl/css :modal-content)}
[:> input* {:label (tr "workspace.pull-requests.create.name-label")
[:> modal-content* {:class (stl/css :dialog-content)}
[:> input* {:label (tr "workspace.pull-requests.create.name-label")
:icon i/git-pull-request-arrow
:placeholder (tr "workspace.pull-requests.create.name-placeholder")
:value title
:on-change on-title-change}]
[:div {:class (stl/css :field)}
[:label {:class (stl/css :field-label)}
(tr "workspace.pull-requests.create.description-label")]
[:textarea {:class (stl/css :textarea)
:value description
:placeholder (tr "workspace.pull-requests.create.description-placeholder")
:on-change on-description-change
:rows 3}]]
[:div {:class (stl/css :field)}
[:label {:class (stl/css :field-label)}
(tr "workspace.pull-requests.create.reviewers-label")]
(if (empty? members)
[:span {:class (stl/css :reviewers-empty)}
(tr "workspace.pull-requests.create.no-members")]
[:ul {:class (stl/css :reviewers-list)}
(for [member members]
[:li {:key (dm/str (:id member))}
[:button {:class (stl/css-case :reviewer-option true
:is-selected (contains? selected (:id member)))
:data-reviewer-id (dm/str (:id member))
:on-click on-toggle-reviewer}
[:> avatar* {:profile member :variant "S"}]
[:span {:class (stl/css :reviewer-name)} (:fullname member)]
(when (contains? selected (:id member))
[:> i/icon* {:icon-id i/tick :size "s"}])]])])]
[:> context-notification* {:level :info :type :context}
(tr "workspace.pull-requests.create.info")]]
[:> modal-footer* {:class (stl/css :dialog-footer)}
[:> button* {:variant "secondary" :on-click on-close}
(tr "labels.cancel")]
[:> button* {:variant "primary"
:icon i/git-pull-request-arrow
:placeholder (tr "workspace.pull-requests.create.name-placeholder")
:value title
:on-change on-title-change}]
[:div {:class (stl/css :field)}
[:label {:class (stl/css :field-label)}
(tr "workspace.pull-requests.create.description-label")]
[:textarea {:class (stl/css :textarea)
:value description
:placeholder (tr "workspace.pull-requests.create.description-placeholder")
:on-change on-description-change
:rows 3}]]
[:div {:class (stl/css :field)}
[:label {:class (stl/css :field-label)}
(tr "workspace.pull-requests.create.reviewers-label")]
(if (empty? members)
[:span {:class (stl/css :reviewers-empty)}
(tr "workspace.pull-requests.create.no-members")]
[:ul {:class (stl/css :reviewers-list)}
(for [member members]
[:li {:key (dm/str (:id member))}
[:button {:class (stl/css-case :reviewer-option true
:is-selected (contains? selected (:id member)))
:data-reviewer-id (dm/str (:id member))
:on-click on-toggle-reviewer}
[:> avatar* {:profile member :variant "S"}]
[:span {:class (stl/css :reviewer-name)} (:fullname member)]
(when (contains? selected (:id member))
[:> i/icon* {:icon-id i/tick :size "s"}])]])])]
[:> context-notification* {:level :info :type :context}
(tr "workspace.pull-requests.create.info")]]
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "secondary" :on-click on-close}
(tr "labels.cancel")]
[:> button* {:variant "primary"
:icon i/git-pull-request-arrow
:disabled (not valid?)
:on-click on-submit}
(tr "workspace.pull-requests.create.submit")]]]]))
:disabled (not valid?)
:on-click on-submit}
(tr "workspace.pull-requests.create.submit")]]]))
;; --- Review dialog (approve / request changes)
@ -261,58 +292,52 @@
(when-not (str/blank? c) c)))
(modal/hide))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:div {:class (stl/css :modal-header-icon)}
[:> i/icon* {:icon-id i/git-pull-request-arrow}]]
[:div {:class (stl/css :modal-header-text)}
[:h2 {:class (stl/css :modal-title)} (tr "workspace.pull-requests.review.title")]
[:span {:class (stl/css :modal-subtitle)} (:title pr)]]
[:> button* {:variant "ghost"
:icon i/close
:aria-label (tr "labels.close")
:on-click on-close}]]
[:> modal* {:is-open true
:on-open-change (on-dismiss on-close)
:size "large"}
[:> dialog-header* {:icon i/git-pull-request-arrow
:title (tr "workspace.pull-requests.review.title")
:subtitle (:title pr)}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :verdict-options)}
[:button {:class (stl/css-case :verdict-option true
:is-selected (= verdict "approved"))
:data-verdict "approved"
:on-click on-select-verdict}
[:> i/icon* {:icon-id i/tick}]
[:div {:class (stl/css :verdict-option-text)}
[:span {:class (stl/css :verdict-option-title)}
(tr "workspace.pull-requests.review.approve")]
[:span {:class (stl/css :verdict-option-hint)}
(tr "workspace.pull-requests.review.approve-hint")]]]
[:> modal-content* {:class (stl/css :dialog-content)}
[:div {:class (stl/css :verdict-options)}
[:button {:class (stl/css-case :verdict-option true
:is-selected (= verdict "approved"))
:data-verdict "approved"
:on-click on-select-verdict}
[:> i/icon* {:icon-id i/tick}]
[:div {:class (stl/css :verdict-option-text)}
[:span {:class (stl/css :verdict-option-title)}
(tr "workspace.pull-requests.review.approve")]
[:span {:class (stl/css :verdict-option-hint)}
(tr "workspace.pull-requests.review.approve-hint")]]]
[:button {:class (stl/css-case :verdict-option true
:is-selected (= verdict "changes-requested"))
:data-verdict "changes-requested"
:on-click on-select-verdict}
[:> i/icon* {:icon-id i/triangle-alert}]
[:div {:class (stl/css :verdict-option-text)}
[:span {:class (stl/css :verdict-option-title)}
(tr "workspace.pull-requests.review.request-changes")]
[:span {:class (stl/css :verdict-option-hint)}
(tr "workspace.pull-requests.review.request-changes-hint")]]]]
[:button {:class (stl/css-case :verdict-option true
:is-selected (= verdict "changes-requested"))
:data-verdict "changes-requested"
:on-click on-select-verdict}
[:> i/icon* {:icon-id i/triangle-alert}]
[:div {:class (stl/css :verdict-option-text)}
[:span {:class (stl/css :verdict-option-title)}
(tr "workspace.pull-requests.review.request-changes")]
[:span {:class (stl/css :verdict-option-hint)}
(tr "workspace.pull-requests.review.request-changes-hint")]]]]
[:div {:class (stl/css :field)}
[:label {:class (stl/css :field-label)}
(tr "workspace.pull-requests.review.comment-label")]
[:textarea {:class (stl/css :textarea)
:value comment
:placeholder (tr "workspace.pull-requests.review.comment-placeholder")
:on-change on-comment-change
:rows 3}]]]
[:div {:class (stl/css :field)}
[:label {:class (stl/css :field-label)}
(tr "workspace.pull-requests.review.comment-label")]
[:textarea {:class (stl/css :textarea)
:value comment
:placeholder (tr "workspace.pull-requests.review.comment-placeholder")
:on-change on-comment-change
:rows 3}]]]
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "secondary" :on-click on-close}
(tr "labels.cancel")]
[:> button* {:variant "primary"
:on-click on-submit}
(tr "workspace.pull-requests.review.submit")]]]]))
[:> modal-footer* {:class (stl/css :dialog-footer)}
[:> button* {:variant "secondary" :on-click on-close}
(tr "labels.cancel")]
[:> button* {:variant "primary"
:on-click on-submit}
(tr "workspace.pull-requests.review.submit")]]]))
;; --- Details dialog
@ -366,16 +391,19 @@
(st/emit! (modal/hide)
(dwpr/reopen-pull-request (:id pr)))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:div {:class (stl/css :modal-header-icon)}
[:> i/icon* {:icon-id i/git-pull-request-arrow}]]
[:div {:class (stl/css :modal-header-text)}
[:h2 {:class (stl/css :modal-title)} (:title pr)]
[:div {:class (stl/css :modal-subtitle-row)}
[:> modal* {:is-open true
:on-open-change (on-dismiss on-close)
:size "large"}
[:> dialog-header*
{:icon i/git-pull-request-arrow
:title (:title pr)
:subtitle
(mf/html
[:div {:class (stl/css :dialog-subtitle-row)}
[:> pr-state-badge* {:pr pr}]
[:span {:class (stl/css :modal-subtitle)}
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :dialog-subtitle)}
;; the route reads branch → target BRANCH (reviews always merge
;; into main); the target file name would be redundant here,
;; inside the file itself
@ -385,94 +413,90 @@
(tr "workspace.pull-requests.badge.outdated")])
(when (and open? (pos? (or (:conflicts pr) 0)))
[:span {:class (stl/css :pr-badge :badge-conflicts)}
(tr "workspace.pull-requests.badge.conflicts" (dm/str (:conflicts pr)))])]]
[:> button* {:variant "ghost"
:icon i/close
:aria-label (tr "labels.close")
:on-click on-close}]]
(tr "workspace.pull-requests.badge.conflicts" (dm/str (:conflicts pr)))])])}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :pr-info-card)}
[:div {:class (stl/css :pr-info-grid)}
[:div {:class (stl/css :pr-info-item)}
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.author")]
[:div {:class (stl/css :pr-info-value)}
(when author [:> avatar* {:profile author :variant "S"}])
[:span (get author :fullname "—")]]]
[:> modal-content* {:class (stl/css :dialog-content)}
[:div {:class (stl/css :pr-info-card)}
[:div {:class (stl/css :pr-info-grid)}
[:div {:class (stl/css :pr-info-item)}
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.author")]
[:div {:class (stl/css :pr-info-value)}
(when author [:> avatar* {:profile author :variant "S"}])
[:span (get author :fullname "—")]]]
[:div {:class (stl/css :pr-info-item)}
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.created-at")]
[:div {:class (stl/css :pr-info-value)}
[:span (ct/format-inst (:created-at pr) :localized-date-time)]]]
[:div {:class (stl/css :pr-info-item)}
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.created-at")]
[:div {:class (stl/css :pr-info-value)}
[:span (ct/format-inst (:created-at pr) :localized-date-time)]]]
[:div {:class (stl/css :pr-info-item)}
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.updated-at")]
[:div {:class (stl/css :pr-info-value)}
[:span (ct/format-inst (:review-updated-at pr) :localized-date-time)]]]
[:div {:class (stl/css :pr-info-item)}
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.updated-at")]
[:div {:class (stl/css :pr-info-value)}
[:span (ct/format-inst (:review-updated-at pr) :localized-date-time)]]]
[:div {:class (stl/css :pr-info-item)}
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.approvals")]
[:div {:class (stl/css :pr-info-value)}
[:span (dm/str approvals "/" total)]]]]
[:div {:class (stl/css :pr-info-item)}
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.approvals")]
[:div {:class (stl/css :pr-info-value)}
[:span (dm/str approvals "/" total)]]]]
(when (pos? total)
[:div {:class (stl/css :field)}
[:label {:class (stl/css :field-label)}
(tr "workspace.pull-requests.info.reviewers")]
[:ul {:class (stl/css :pr-info-reviewers)}
(for [review (:reviews pr)]
(let [reviewer (get profiles (:profile-id review))]
[:li {:key (dm/str (:profile-id review))
:class (stl/css :pr-info-reviewer)}
(when reviewer [:> avatar* {:profile reviewer :variant "S"}])
[:span {:class (stl/css :reviewer-name)}
(get reviewer :fullname "—")]
[:span {:class (stl/css-case :reviewer-state true
:reviewer-approved (and (= "approved" (:state review))
(not (:stale review)))
:reviewer-changes (and (= "changes-requested" (:state review))
(not (:stale review)))
:reviewer-stale (:stale review))}
(cond
(:stale review)
(tr "workspace.pull-requests.review-state.stale")
(= "approved" (:state review))
(tr "workspace.pull-requests.review-state.approved")
(= "changes-requested" (:state review))
(tr "workspace.pull-requests.review-state.changes-requested")
:else
(tr "workspace.pull-requests.review-state.pending"))]
(when-not (str/blank? (:comment review))
[:p {:class (stl/css :reviewer-comment)} (:comment review)])]))]])]
(when-not (str/blank? (:description pr))
[:div {:class (stl/css :pr-info-description-block)}
(when (pos? total)
[:div {:class (stl/css :field)}
[:label {:class (stl/css :field-label)}
(tr "workspace.pull-requests.info.description")]
[:p {:class (stl/css :pr-info-description)} (:description pr)]])]
(tr "workspace.pull-requests.info.reviewers")]
[:ul {:class (stl/css :pr-info-reviewers)}
(for [review (:reviews pr)]
(let [reviewer (get profiles (:profile-id review))]
[:li {:key (dm/str (:profile-id review))
:class (stl/css :pr-info-reviewer)}
(when reviewer [:> avatar* {:profile reviewer :variant "S"}])
[:span {:class (stl/css :reviewer-name)}
(get reviewer :fullname "—")]
[:span {:class (stl/css-case :reviewer-state true
:reviewer-approved (and (= "approved" (:state review))
(not (:stale review)))
:reviewer-changes (and (= "changes-requested" (:state review))
(not (:stale review)))
:reviewer-stale (:stale review))}
(cond
(:stale review)
(tr "workspace.pull-requests.review-state.stale")
[:div {:class (stl/css :modal-footer)}
(when (and (or author? admin?) open?)
[:> button* {:variant "secondary"
:on-click on-close-pr}
(tr "workspace.pull-requests.actions.close")])
(when (and (or author? admin?) closed?)
[:> button* {:variant "secondary"
:on-click on-reopen-pr}
(tr "workspace.pull-requests.actions.reopen")])
(when (and open? (not reviewing?))
[:> button* {:variant "primary"
:icon i/arrow-up-right
:on-click on-open-review}
(tr "workspace.pull-requests.actions.open-review")])
(when (and open? can-merge?)
[:> button* {:variant "primary"
:icon i/git-merge
:on-click on-merge}
(tr "workspace.pull-requests.actions.merge")])]]]))
(= "approved" (:state review))
(tr "workspace.pull-requests.review-state.approved")
(= "changes-requested" (:state review))
(tr "workspace.pull-requests.review-state.changes-requested")
:else
(tr "workspace.pull-requests.review-state.pending"))]
(when-not (str/blank? (:comment review))
[:p {:class (stl/css :reviewer-comment)} (:comment review)])]))]])]
(when-not (str/blank? (:description pr))
[:div {:class (stl/css :pr-info-description-block)}
[:label {:class (stl/css :field-label)}
(tr "workspace.pull-requests.info.description")]
[:p {:class (stl/css :pr-info-description)} (:description pr)]])]
[:> modal-footer* {:class (stl/css :dialog-footer)}
(when (and (or author? admin?) open?)
[:> button* {:variant "secondary"
:on-click on-close-pr}
(tr "workspace.pull-requests.actions.close")])
(when (and (or author? admin?) closed?)
[:> button* {:variant "secondary"
:on-click on-reopen-pr}
(tr "workspace.pull-requests.actions.reopen")])
(when (and open? (not reviewing?))
[:> button* {:variant "primary"
:icon i/arrow-up-right
:on-click on-open-review}
(tr "workspace.pull-requests.actions.open-review")])
(when (and open? can-merge?)
[:> button* {:variant "primary"
:icon i/git-merge
:on-click on-merge}
(tr "workspace.pull-requests.actions.merge")])]]))
;; --- Review sandbox banner (replaces the branch banner inside a sandbox)
@ -488,6 +512,9 @@
;; branch file, so the team role tells whether this user could
;; integrate the branch
can-merge? (boolean (get-in team [:permissions :can-edit]))
;; same rule as the details dialog's "Cancel pull request"
can-cancel? (and (or author? (boolean (get-in team [:permissions :is-admin])))
(= "open" (:status pr)))
[approvals total] (current-approvals pr)
collapsed* (mf/use-state false)
@ -495,19 +522,18 @@
on-toggle (mf/use-fn #(swap! collapsed* not))
;; secondary actions live behind the kebab menu so the banner
;; only exposes the essential ones (details / compare / review)
menu-open* (mf/use-state false)
menu-open? (deref menu-open*)
on-open-menu (mf/use-fn
(fn [event]
;; the dropdown closes itself on any outside
;; click: the opening one must not reach it
(dom/stop-propagation event)
(reset! menu-open* true)))
on-close-menu (mf/use-fn #(reset! menu-open* false))
;; only exposes the essential ones (compare / review)
menu-open* (mf/use-state false)
menu-open? (deref menu-open*)
on-toggle-menu (mf/use-fn #(swap! menu-open* not))
on-menu-change (mf/use-fn #(reset! menu-open* %))
on-info
(mf/use-fn (mf/deps pr) #(modal/show! :pull-request-info {:pr pr}))
(mf/use-fn
(mf/deps pr)
(fn [_]
(reset! menu-open* false)
(modal/show! :pull-request-info {:pr pr})))
on-compare
(mf/use-fn (mf/deps pr) #(open-compare! pr))
@ -528,12 +554,12 @@
(reset! menu-open* false)
(confirm-merge! pr)))
on-play
on-cancel
(mf/use-fn
(mf/deps pr)
(fn [_]
(reset! menu-open* false)
(st/emit! (dwpr/open-pull-request-viewer pr))))
(confirm-close! pr)))
on-publish
(mf/use-fn
@ -563,7 +589,9 @@
(tr "workspace.pull-requests.banner.reviewing")]
[:span {:class (stl/css :pr-banner-name)} (:title pr)]
[:span {:class (stl/css :pr-banner-route)}
(dm/str (:branch-name pr) " → " (tr "workspace.branches.main"))]]
[:> branch-pill* {:label (:branch-name pr) :is-review true}]
[:> i/icon* {:icon-id i/arrow-long-right :size "s"}]
[:> branch-pill* {:label (tr "workspace.branches.main") :is-main true}]]]
[:div {:class (stl/css :pr-banner-badges)}
[:> pr-state-badge* {:pr pr}]
@ -579,10 +607,6 @@
(dm/str approvals "/" total)])]
[:div {:class (stl/css :pr-banner-actions)}
[:> button* {:variant "secondary"
:on-click on-info}
(tr "workspace.pull-requests.menu.info")]
[:> button* {:variant "secondary"
:icon i/switch
:on-click on-compare}
@ -594,21 +618,29 @@
:on-click on-review}
(tr "workspace.pull-requests.banner.review")])
;; the banner sits at the bottom of the canvas: its menu opens
;; upwards
[:div {:class (stl/css :pr-banner-menu)}
[:> icon-button* {:variant "ghost"
:icon i/menu
:aria-label (tr "labels.options")
:on-click on-open-menu}]
[:> dropdown-menu* {:show menu-open?
:on-close on-close-menu
:class (stl/css :pr-banner-options-dropdown)}
[:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-play}
(tr "workspace.pull-requests.banner.play")]
[:> menu* {:is-open menu-open?
:on-open-change on-menu-change
:placement "top end"
:trigger
(mf/html
[:> icon-button* {:variant "ghost"
:icon i/menu
:aria-label (tr "labels.options")
:aria-pressed menu-open?
:on-click on-toggle-menu}])}
[:> menu-item* {:id "info" :on-action on-info}
(tr "workspace.pull-requests.menu.info")]
(when (and author? (:outdated pr))
[:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-publish}
[:> menu-item* {:id "publish" :on-action on-publish}
(tr "workspace.pull-requests.banner.publish")])
(when can-merge?
[:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-merge}
[:> menu-item* {:id "merge" :on-action on-merge}
(tr "workspace.pull-requests.actions.merge")])
[:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-exit}
(tr "workspace.pull-requests.banner.exit")]]]]]])))
[:> menu-item* {:id "exit" :on-action on-exit}
(tr "workspace.pull-requests.banner.exit")]
(when can-cancel?
[:> menu-item* {:id "cancel" :on-action on-cancel}
(tr "workspace.pull-requests.banner.cancel")])]]]]])))

View File

@ -4,229 +4,42 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as t;
@use "refactor/common-refactor.scss" as deprecated;
// Same light-theme semantic-tint re-pointing the branches surfaces use
// (see branches.scss for the rationale).
.pr-section,
.pr-banner,
.modal-container {
:global(.light) & {
--color-foreground-success: var(--color-accent-success);
--color-foreground-error: var(--color-accent-error);
--color-foreground-warning: color-mix(in srgb, var(--color-accent-warning) 60%, #000);
--color-foreground-info: var(--color-accent-info);
}
}
// --- Sidebar section
.pr-section {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.pr-section-header {
display: flex;
align-items: center;
gap: var(--sp-xs);
padding-block: var(--sp-s) var(--sp-xs);
padding-inline: var(--sp-s);
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
> button {
margin-inline-start: auto;
}
}
.pr-section-count {
color: var(--color-foreground-secondary);
}
.pr-section-empty {
@include t.use-typography("body-small");
padding-inline: var(--sp-s);
color: var(--color-foreground-secondary);
}
.pr-entries {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
margin: 0;
padding: 0;
list-style: none;
}
.pr-entry {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: var(--sp-s);
padding: var(--sp-s);
border-radius: 8px;
cursor: pointer;
&:hover {
background-color: var(--color-background-tertiary);
}
}
.pr-entry.is-finished {
opacity: 0.7;
}
.pr-entry.is-menu-open {
position: relative;
z-index: 1;
}
.pr-entry-icon {
display: flex;
align-items: center;
justify-content: center;
inline-size: 32px;
block-size: 32px;
border-radius: 8px;
background-color: var(--color-background-tertiary);
color: var(--color-accent-primary);
}
.pr-entry-body {
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
min-width: 0;
}
.pr-entry-title {
@include t.use-typography("body-medium");
color: var(--color-foreground-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pr-entry-meta {
display: flex;
align-items: center;
gap: var(--sp-xs);
min-width: 0;
}
.pr-entry-route {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pr-entry-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-xs);
}
.pr-entry-aside {
position: relative;
display: flex;
align-items: center;
gap: var(--sp-xs);
}
.pr-entry-approvals {
@include t.use-typography("code-font");
display: inline-flex;
align-items: center;
gap: var(--sp-xxs);
font-weight: 600;
color: var(--color-foreground-success);
}
.pr-options-dropdown {
@extend %menu-dropdown;
inset-block-start: 100%;
inset-inline-end: 0;
inline-size: $sz-160;
}
.menu-option {
@extend %menu-item-base;
white-space: nowrap;
}
.pr-finished {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.pr-finished-toggle {
@include t.use-typography("body-small");
display: flex;
align-items: center;
gap: var(--sp-xs);
padding-block: var(--sp-s) var(--sp-xs);
padding-inline: var(--sp-s);
border: none;
background: none;
cursor: pointer;
color: var(--color-foreground-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
@use "ds/typography.scss" as *;
// --- State badges
.pr-badge {
@include t.use-typography("body-small");
@include use-typography("body-small");
padding: var(--sp-xxs) var(--sp-s);
border-radius: 12px;
border-radius: $br-12;
white-space: nowrap;
}
.badge-open {
color: var(--color-foreground-info);
color: var(--color-accent-info);
background-color: var(--color-background-info);
}
.badge-in-review {
color: var(--color-foreground-info);
color: var(--color-accent-info);
background-color: var(--color-background-info);
}
.badge-approved {
color: var(--color-foreground-success);
color: var(--color-accent-success);
background-color: var(--color-background-success);
}
.badge-changes {
color: var(--color-foreground-warning);
color: var(--color-accent-warning);
background-color: var(--color-background-warning);
}
.badge-merged {
color: var(--color-foreground-success);
color: var(--color-accent-success);
background-color: var(--color-background-success);
}
@ -236,7 +49,7 @@
}
.badge-outdated {
color: var(--color-foreground-warning);
color: var(--color-accent-warning);
background-color: var(--color-background-warning);
}
@ -245,91 +58,68 @@
background-color: var(--color-background-error);
}
// --- Modals (create / review / details)
// --- Dialogs (create / review / details)
//
// `dialog-header*` lives here and the branch dialogs use it too.
.modal-overlay {
@extend %modal-overlay-base;
}
.modal-container {
@extend %modal-container-base;
min-width: deprecated.$s-408;
// long pull request descriptions: let the card grow past the base
// 512px cap up to the viewport, and scroll the CONTENT (header and
// footer stay pinned) instead of spilling out of the card
display: flex;
flex-direction: column;
max-block-size: min(90vh, 56rem);
max-inline-size: min(90vw, 40rem);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
// room for the close button `modal*` places over the top-right corner
.dialog-header {
gap: var(--sp-s);
margin-bottom: deprecated.$s-24;
padding-inline-end: var(--sp-s);
}
.modal-header-icon {
.dialog-header-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: $sz-32;
height: $sz-32;
border-radius: 8px;
inline-size: $sz-32;
block-size: $sz-32;
border-radius: $br-8;
color: var(--color-accent-primary);
background-color: var(--color-background-tertiary);
&.is-warning {
color: var(--color-accent-warning);
}
}
.modal-header-text {
.dialog-header-text {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--sp-xxs);
flex: 1;
min-inline-size: 0;
}
.modal-title {
@include deprecated.headline-medium-typography;
color: var(--modal-title-foreground-color);
.dialog-title {
color: var(--color-foreground-primary);
overflow-wrap: anywhere;
}
.modal-subtitle {
@include t.use-typography("body-small");
.dialog-subtitle {
color: var(--color-foreground-secondary);
overflow-wrap: anywhere;
}
.modal-subtitle-row {
.dialog-subtitle-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-xs);
}
.modal-content {
@include deprecated.flex-column;
gap: deprecated.$s-16;
margin-bottom: deprecated.$s-24;
flex: 1 1 auto;
min-block-size: 0;
overflow-y: auto;
.dialog-content {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.modal-footer {
display: flex;
justify-content: flex-end;
// long labels: the row of actions wraps, never the label inside a button
.dialog-footer {
flex-wrap: wrap;
gap: var(--sp-s);
// action labels never wrap inside their button; the row wraps instead
// button: the DS buttons in the footer take no class from this module
button {
white-space: nowrap;
}
@ -342,28 +132,28 @@
}
.field-label {
@include t.use-typography("body-small");
@include use-typography("body-small");
color: var(--color-foreground-secondary);
}
.textarea {
width: 100%;
min-height: $sz-64;
inline-size: 100%;
min-block-size: $sz-64;
padding: var(--sp-s);
border-radius: 8px;
border: 1px solid var(--color-background-quaternary);
border-radius: $br-8;
border: $b-1 solid var(--color-background-quaternary);
background-color: var(--color-background-tertiary);
color: var(--color-foreground-primary);
resize: vertical;
@include t.use-typography("body-medium");
@include use-typography("body-medium");
}
// --- Reviewer picker (create dialog)
.reviewers-empty {
@include t.use-typography("body-small");
@include use-typography("body-small");
color: var(--color-foreground-secondary);
}
@ -385,8 +175,8 @@
gap: var(--sp-s);
inline-size: 100%;
padding: var(--sp-xs) var(--sp-s);
border: 1px solid transparent;
border-radius: 8px;
border: $b-1 solid transparent;
border-radius: $br-8;
background: none;
cursor: pointer;
color: var(--color-foreground-primary);
@ -407,7 +197,7 @@
}
.reviewer-name {
@include t.use-typography("body-medium");
@include use-typography("body-medium");
white-space: nowrap;
overflow: hidden;
@ -427,8 +217,8 @@
align-items: center;
gap: var(--sp-m);
padding: var(--sp-m);
border: 1px solid var(--color-background-quaternary);
border-radius: 8px;
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
background: none;
cursor: pointer;
text-align: start;
@ -451,13 +241,11 @@
}
.verdict-option-title {
@include t.use-typography("body-medium");
font-weight: 600;
@include use-typography("body-medium");
}
.verdict-option-hint {
@include t.use-typography("body-small");
@include use-typography("body-small");
color: var(--color-foreground-secondary);
}
@ -467,15 +255,15 @@
// the pull request metadata (author / dates / approvals / reviewers)
// gets its own card, visually separated from the free-text description
// (same surface treatment as the reviewer rows and the create dialog's
// "derived from" box: tertiary background + 8px radius)
// "derived from" box: tertiary background + $br-8 radius)
.pr-info-card {
display: flex;
flex-direction: column;
gap: var(--sp-m);
flex-shrink: 0;
padding: var(--sp-m);
border-radius: 8px;
border: 1px solid var(--color-background-quaternary);
border-radius: $br-8;
border: $b-1 solid var(--color-background-quaternary);
background-color: var(--color-background-tertiary);
}
@ -491,7 +279,7 @@
}
.pr-info-description {
@include t.use-typography("body-medium");
@include use-typography("body-medium");
margin: 0;
color: var(--color-foreground-primary);
@ -520,7 +308,7 @@
align-items: center;
gap: var(--sp-xs);
@include t.use-typography("body-medium");
@include use-typography("body-medium");
color: var(--color-foreground-primary);
}
@ -542,7 +330,7 @@
flex-wrap: wrap;
gap: var(--sp-s);
padding: var(--sp-xs) var(--sp-s);
border-radius: 8px;
border-radius: $br-8;
// sits INSIDE the tertiary metadata card: step one surface down so
// the rows remain distinguishable
@ -550,18 +338,18 @@
}
.reviewer-state {
@include t.use-typography("body-small");
@include use-typography("body-small");
margin-inline-start: auto;
color: var(--color-foreground-secondary);
}
.reviewer-approved {
color: var(--color-foreground-success);
color: var(--color-accent-success);
}
.reviewer-changes {
color: var(--color-foreground-warning);
color: var(--color-accent-warning);
}
.reviewer-stale {
@ -570,7 +358,7 @@
}
.reviewer-comment {
@include t.use-typography("body-small");
@include use-typography("body-small");
flex-basis: 100%;
margin: 0;
@ -586,21 +374,18 @@
inset-inline-start: 50%;
transform: translateX(-50%);
max-inline-size: 90vw;
border-radius: deprecated.$s-8;
border-radius: $br-8;
// the review banner must stand out from the branch banner it replaces
// (the canvas is still the live branch): accent-colored border instead
// of the branch banner's neutral one
border: deprecated.$s-2 solid var(--color-accent-primary);
// (the canvas is still the live branch): info blue border, as every
// review mark, instead of the branch banner's neutral one
border: $b-2 solid var(--color-accent-info);
background-color: var(--color-background-primary);
// unlike the branch banner this one hosts a dropdown that opens
// upwards, outside the banner box: it must not be clipped (the
// collapsed state hides its content via block-size + visibility) and
// it must beat the viewport controls, which are raised to z-index 1
// while a drawing tool is active
overflow: visible;
z-index: deprecated.$z-index-2;
// TODO: no DS z-index token for 2.
z-index: 2;
transition:
inset-block-end 0.2s,
block-size 0.2s;
@ -608,7 +393,7 @@
&.is-collapsed {
inset-block-end: 0;
block-size: var(--sp-m);
border-radius: 12px 12px 0 0;
border-radius: $br-12 $br-12 0 0;
.pr-banner-content {
block-size: 0;
@ -619,13 +404,16 @@
}
.pr-banner-handle {
@include deprecated.flex-center;
@include deprecated.button-style;
position: absolute;
inset-inline: 0;
inset-block-start: 0;
display: flex;
justify-content: center;
align-items: center;
block-size: var(--sp-m);
border: none;
background: none;
cursor: pointer;
inline-size: 100%;
z-index: 1;
}
@ -633,10 +421,10 @@
.pr-banner-handle-btn {
block-size: var(--sp-xs);
inline-size: 100%;
max-inline-size: 64px;
max-inline-size: $sz-64;
padding: 0;
border-radius: var(--sp-xs);
background-color: var(--palette-handler-background-color);
border-radius: $br-4;
background-color: var(--color-background-quaternary);
}
.pr-banner-content {
@ -644,23 +432,26 @@
align-items: center;
gap: var(--sp-m);
padding: var(--sp-m) var(--sp-m) var(--sp-s);
// no overflow clip: the kebab dropdown opens upwards past the banner
// edge (the collapsed state hides this box via block-size + visibility)
}
.pr-banner-icon {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--color-accent-primary);
justify-content: center;
inline-size: $sz-24;
block-size: $sz-24;
border-radius: $br-4;
background-color: var(--color-background-info);
color: var(--color-accent-info);
}
.pr-banner-text {
display: flex;
align-items: baseline;
align-items: center;
gap: var(--sp-xs);
@include t.use-typography("body-small");
@include use-typography("body-small");
}
.pr-banner-title,
@ -668,9 +459,16 @@
color: var(--color-foreground-secondary);
}
// branch -> main, as pills, the same way the compare dialog names them
.pr-banner-route {
display: flex;
align-items: center;
gap: var(--sp-xs);
white-space: nowrap;
}
.pr-banner-name {
color: var(--color-foreground-primary);
font-weight: 700;
}
.pr-banner-badges {
@ -680,13 +478,12 @@
}
.pr-banner-approvals {
@include t.use-typography("code-font");
@include use-typography("code-font");
display: inline-flex;
align-items: center;
gap: var(--sp-xxs);
font-weight: 600;
color: var(--color-foreground-success);
color: var(--color-accent-success);
}
.pr-banner-actions {
@ -695,18 +492,8 @@
gap: var(--sp-s);
}
// not positioned: the options menu anchors to the banner box itself
.pr-banner-menu {
position: relative;
display: flex;
align-items: center;
}
// the banner sits at the bottom of the canvas, so its options menu
// opens upwards
.pr-banner-options-dropdown {
@extend %menu-dropdown;
inset-block-end: 100%;
inset-inline-end: 0;
inline-size: $sz-192;
}

View File

@ -7,10 +7,14 @@
(ns frontend-tests.data.workspace-branches-test
(:require
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.modal :as modal]
[app.main.data.workspace.branches :as dwb]
[app.main.repo :as rp]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
@ -216,3 +220,91 @@
(rx/dispose! store))))
(done'))
done))))
;;; --- Branch switcher: the entry point of the menu and the shortcut ---
(defn- make-store-with
[state]
(ptk/store {:state state
:on-error (fn [cause] (t/is false (str cause)))}))
(def ^:private file-id (uuid/next))
(def ^:private main-without-branches
{:current-file-id file-id
:files {file-id {:id file-id :name "Checkout"}}
:workspace-branches {:status :loaded :data []}
:workspace-branch-context {:file-id file-id :loaded? true :info nil}})
(def ^:private main-with-branches
(assoc main-without-branches :workspace-branches
{:status :loaded :data [{:id (uuid/next) :name "feature-login" :status "open"}]}))
(def ^:private on-a-branch
(assoc main-without-branches :workspace-branch-context
{:file-id file-id :loaded? true :info {:id (uuid/next) :name "feature-login"}}))
(t/deftest the-switcher-only-exists-once-the-file-has-branches
(t/is (false? (dwb/has-branches? main-without-branches)))
(t/is (true? (dwb/has-branches? main-with-branches)))
(t/is (true? (dwb/has-branches? on-a-branch))
"a branch counts even before the list loads"))
(t/deftest the-switcher-toggles-and-can-be-forced
(let [store (make-store-with main-with-branches)]
(ptk/emit! store (dwb/toggle-branch-switcher))
(t/is (true? (:workspace-branch-switcher @store)))
(ptk/emit! store (dwb/toggle-branch-switcher))
(t/is (false? (:workspace-branch-switcher @store)))
(ptk/emit! store (dwb/toggle-branch-switcher true))
(ptk/emit! store (dwb/toggle-branch-switcher true))
(t/is (true? (:workspace-branch-switcher @store)) "forcing it open twice keeps it open")
(rx/dispose! store)))
(t/deftest ^:async show-branches-opens-the-switcher-when-there-are-branches
(await (mock/with-mocks*
{cf/flags (conj cf/flags :branching)}
(let [store (make-store-with main-with-branches)]
(ptk/emit! store (dwb/show-branches))
(await (async/wait-for #(true? (:workspace-branch-switcher @store))
"the switcher opens"))
(await (async/settle))
(t/is (nil? (::modal/modal @store)) "no dialog opens")
(rx/dispose! store)))))
(t/deftest ^:async show-branches-asks-for-the-first-branch-when-there-are-none
(await (mock/with-mocks*
{cf/flags (conj cf/flags :branching)}
(let [store (make-store-with main-without-branches)]
(ptk/emit! store (dwb/show-branches))
(await (async/wait-for #(some? (::modal/modal @store))
"the create dialog opens"))
(t/is (= :create-branch (get-in @store [::modal/modal :type])))
(t/is (= "Checkout" (get-in @store [::modal/modal :props :file-name])))
(t/is (not (:workspace-branch-switcher @store)))
(rx/dispose! store)))))
(t/deftest ^:async show-branches-does-nothing-without-the-flag
(await (mock/with-mocks*
{cf/flags (disj cf/flags :branching)}
(let [store (make-store-with main-without-branches)]
(ptk/emit! store (dwb/show-branches))
(await (async/settle))
(t/is (nil? (::modal/modal @store)))
(t/is (nil? (:workspace-branch-switcher @store)))
(rx/dispose! store)))))
(t/deftest ^:async opening-a-branch-closes-the-switcher-and-navigates
(let [navigations (atom [])
branch-id (uuid/next)]
(await (mock/with-mocks*
{dcm/go-to-workspace
(fn [& {:as params}]
(swap! navigations conj params)
(ptk/data-event ::navigated params))}
(let [store (make-store-with (assoc main-with-branches :workspace-branch-switcher true))]
(ptk/emit! store (dwb/open-branch branch-id))
(await (async/wait-for #(seq @navigations) "it navigates"))
(t/is (false? (:workspace-branch-switcher @store)))
(t/is (= {:file-id branch-id} (first @navigations)))
(rx/dispose! store))))))

View File

@ -368,9 +368,9 @@ msgstr "Clear trash"
msgid "dashboard.copy-suffix"
msgstr "(copy)"
#: src/app/main/ui/dashboard/file_menu.cljs:314
#, unused
msgid "dashboard.create-branch"
msgstr "Create branch…"
msgstr "Create branch"
#: src/app/main/ui/dashboard/organization_team_switch.cljs:223, src/app/main/ui/dashboard/organization_team_switch.cljs:304
msgid "dashboard.create-new-organization"
@ -6364,7 +6364,7 @@ msgstr "%s conflicts"
msgid "workspace.branches.banner.expand"
msgstr "Expand"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1596
#, unused
msgid "workspace.branches.banner.from-main"
msgstr "from main"
@ -6376,6 +6376,32 @@ msgstr "Merged branch"
msgid "workspace.branches.banner.on-branch"
msgstr "On branch"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1853
msgid "workspace.branches.banner.status.ahead"
msgid_plural "workspace.branches.banner.status.ahead"
msgstr[0] "1 change not in main"
msgstr[1] "%s changes not in main"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1841
msgid "workspace.branches.banner.status.behind"
msgid_plural "workspace.branches.banner.status.behind"
msgstr[0] "main has 1 new change"
msgstr[1] "main has %s new changes"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1836
msgid "workspace.branches.banner.status.conflicts"
msgid_plural "workspace.branches.banner.status.conflicts"
msgstr[0] "1 conflict with main"
msgstr[1] "%s conflicts with main"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1847
msgid "workspace.branches.banner.status.in-review"
msgstr "In review · %s of %s approved"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1858
msgid "workspace.branches.banner.status.up-to-date"
msgstr "Up to date with main"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1602
msgid "workspace.branches.banner.view-main"
msgstr "View in main"
@ -6651,7 +6677,7 @@ msgstr "No conflicts to resolve"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1520
msgid "workspace.branches.conflicts.footer-pending"
msgstr "%s conflict(s) pending · resolve all to merge"
msgstr "%s conflict(s) pending. Resolve all to merge"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1515
msgid "workspace.branches.conflicts.footer-ready"
@ -7012,7 +7038,23 @@ msgstr ""
msgid "workspace.branches.limits.refuse-name"
msgstr "A branch name can't be empty."
#: src/app/main/ui/workspace/sidebar/branches.cljs:799
#: src/app/main/ui/workspace/sidebar/branches.cljs:1000
msgid "workspace.branches.list.empty"
msgstr "No branches here"
#: src/app/main/ui/workspace/sidebar/branches.cljs:945
msgid "workspace.branches.list.tab.active"
msgstr "Active"
#: src/app/main/ui/workspace/sidebar/branches.cljs:944
msgid "workspace.branches.list.tab.mine"
msgstr "Mine"
#: src/app/main/ui/workspace/sidebar/branches.cljs:988
msgid "workspace.branches.list.title"
msgstr "Branches - %s"
#, unused
msgid "workspace.branches.loading"
msgstr "Loading branches…"
@ -7022,7 +7064,7 @@ msgstr "Main"
#: src/app/main/data/workspace/branches.cljs:336
msgid "workspace.branches.main-advanced"
msgstr "main has new changes — you can update this branch"
msgstr "Main has new changes. You can update this branch"
#: src/app/main/ui/workspace/sidebar/branches.cljs:659
msgid "workspace.branches.main-subtitle"
@ -7058,6 +7100,10 @@ msgstr "Branches"
msgid "workspace.branches.menu.archive"
msgstr "Archive"
#: src/app/main/ui/workspace/main_menu.cljs:1152
msgid "workspace.branches.menu.create"
msgstr "Create branch"
#: src/app/main/ui/workspace/sidebar/branches.cljs:682
msgid "workspace.branches.menu.info"
msgstr "Branch info"
@ -7129,7 +7175,7 @@ msgstr ""
"This branch changes elements that can't be merged automatically yet "
"(components, pages or tokens)"
#: src/app/main/ui/dashboard/branches_popover.cljs:84, src/app/main/ui/workspace/sidebar/branches.cljs:793
#, unused
msgid "workspace.branches.new"
msgstr "New"
@ -7161,7 +7207,23 @@ msgstr "Deleted"
msgid "workspace.branches.status.modified"
msgstr "Modified"
#: src/app/main/ui/workspace/sidebar.cljs:261
#: src/app/main/ui/workspace/branch_switcher.cljs:242
msgid "workspace.branches.switcher.all"
msgstr "All branches"
#: src/app/main/ui/workspace/branch_switcher.cljs:238
msgid "workspace.branches.switcher.new"
msgstr "New branch"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1010
msgid "workspace.branches.switcher.new-from-main"
msgstr "Branches are created from main"
#: src/app/main/ui/workspace/branch_switcher.cljs:309
msgid "workspace.branches.switcher.tooltip"
msgstr "Switch branch"
#, unused
msgid "workspace.branches.tab"
msgstr "Branches"
@ -9216,14 +9278,14 @@ msgstr "%s conflicts"
msgid "workspace.pull-requests.badge.outdated"
msgstr "New changes on branch"
#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:623
msgid "workspace.pull-requests.banner.cancel"
msgstr "Cancel review"
#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:759
msgid "workspace.pull-requests.banner.exit"
msgstr "Exit review"
#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:736
msgid "workspace.pull-requests.banner.play"
msgstr "Try interactions"
#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:742
msgid "workspace.pull-requests.banner.publish"
msgstr "Publish latest changes"
@ -9265,10 +9327,9 @@ msgstr "An error occurred while creating the pull request"
#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:193
msgid "workspace.pull-requests.create.info"
msgstr ""
"Reviewers will open the branch itself, so any edit they make changes the "
"branch. Try interactions plays the branch as published now, and later edits "
"show there only after newer changes are published. You can keep working on "
"the branch and publish newer changes at any time."
"Reviewers open the branch itself, so any edit they make changes the branch, "
"and View mode plays the branch as it is now. You can keep working on the "
"branch and publish newer changes at any time."
#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:159
msgid "workspace.pull-requests.create.name-label"
@ -9306,6 +9367,10 @@ msgstr "Create pull request"
msgid "workspace.pull-requests.header-badge-tooltip"
msgstr "Reviewing pull request: %s"
#: src/app/main/ui/workspace/left_header.cljs:177
msgid "workspace.pull-requests.header-label"
msgstr "Review:"
#: src/app/main/ui/workspace/left_header.cljs:54
msgid "workspace.pull-requests.header-title"
msgstr "Review: %s"

View File

@ -6416,6 +6416,68 @@ msgstr "Transformar texto"
msgid "workspace.assets.ungroup"
msgstr "Desagrupar"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1853
msgid "workspace.branches.banner.status.ahead"
msgid_plural "workspace.branches.banner.status.ahead"
msgstr[0] "1 cambio que no está en main"
msgstr[1] "%s cambios que no están en main"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1841
msgid "workspace.branches.banner.status.behind"
msgid_plural "workspace.branches.banner.status.behind"
msgstr[0] "main tiene 1 cambio nuevo"
msgstr[1] "main tiene %s cambios nuevos"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1836
msgid "workspace.branches.banner.status.conflicts"
msgid_plural "workspace.branches.banner.status.conflicts"
msgstr[0] "1 conflicto con main"
msgstr[1] "%s conflictos con main"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1847
msgid "workspace.branches.banner.status.in-review"
msgstr "En revisión · %s de %s aprobaciones"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1858
msgid "workspace.branches.banner.status.up-to-date"
msgstr "Al día con main"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1000
msgid "workspace.branches.list.empty"
msgstr "No hay ramas aquí"
#: src/app/main/ui/workspace/sidebar/branches.cljs:945
msgid "workspace.branches.list.tab.active"
msgstr "Activas"
#: src/app/main/ui/workspace/sidebar/branches.cljs:944
msgid "workspace.branches.list.tab.mine"
msgstr "Mías"
#: src/app/main/ui/workspace/sidebar/branches.cljs:988
msgid "workspace.branches.list.title"
msgstr "Ramas - %s"
#: src/app/main/ui/workspace/main_menu.cljs:1152
msgid "workspace.branches.menu.create"
msgstr "Crear rama"
#: src/app/main/ui/workspace/branch_switcher.cljs:242
msgid "workspace.branches.switcher.all"
msgstr "Todas las ramas"
#: src/app/main/ui/workspace/branch_switcher.cljs:238
msgid "workspace.branches.switcher.new"
msgstr "Nueva rama"
#: src/app/main/ui/workspace/sidebar/branches.cljs:1010
msgid "workspace.branches.switcher.new-from-main"
msgstr "Las ramas se crean desde main"
#: src/app/main/ui/workspace/branch_switcher.cljs:309
msgid "workspace.branches.switcher.tooltip"
msgstr "Cambiar de rama"
#: src/app/main/data/workspace/clipboard.cljs:1170
msgid "workspace.clipboard.copying"
msgstr "Copiando imagen…"
@ -9692,3 +9754,11 @@ msgstr "Si quieres aumentar este límite, contáctanos en [support@penpot.app](%
#: src/app/main/ui/workspace/sidebar/versions.cljs:462
msgid "workspace.versions.warning.text"
msgstr "Los autoguardados duran %s días."
#: src/app/main/ui/workspace/left_header.cljs:177
msgid "workspace.pull-requests.header-label"
msgstr "Revisión:"
#: src/app/main/ui/workspace/sidebar/pull_requests.cljs:623
msgid "workspace.pull-requests.banner.cancel"
msgstr "Cancelar revisión"