diff --git a/frontend/playwright/data/pull-requests/get-file-branch-info-uncompared.json b/frontend/playwright/data/pull-requests/get-file-branch-info-uncompared.json
new file mode 100644
index 0000000000..5334db4245
--- /dev/null
+++ b/frontend/playwright/data/pull-requests/get-file-branch-info-uncompared.json
@@ -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
+}
diff --git a/frontend/playwright/ui/specs/pull-requests.spec.js b/frontend/playwright/ui/specs/pull-requests.spec.js
index 53c92dd9ef..0c5b3386f0 100644
--- a/frontend/playwright/ui/specs/pull-requests.spec.js
+++ b/frontend/playwright/ui/specs/pull-requests.spec.js
@@ -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();
+});
diff --git a/frontend/src/app/main/data/workspace/branches.cljs b/frontend/src/app/main/data/workspace/branches.cljs
index 21484ca2ee..1a02748a05 100644
--- a/frontend/src/app/main/data/workspace/branches.cljs
+++ b/frontend/src/app/main/data/workspace/branches.cljs
@@ -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)
diff --git a/frontend/src/app/main/data/workspace/pull_requests.cljs b/frontend/src/app/main/data/workspace/pull_requests.cljs
index 17a0db7d64..46676699be 100644
--- a/frontend/src/app/main/data/workspace/pull_requests.cljs
+++ b/frontend/src/app/main/data/workspace/pull_requests.cljs
@@ -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
diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs
index 2775eb964f..6956305804 100644
--- a/frontend/src/app/main/data/workspace/shortcuts.cljs
+++ b/frontend/src/app/main/data/workspace/shortcuts.cljs
@@ -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"))
diff --git a/frontend/src/app/main/ui/dashboard/branches_popover.cljs b/frontend/src/app/main/ui/dashboard/branches_popover.cljs
index 7c617776ab..93821f4dd6 100644
--- a/frontend/src/app/main/ui/dashboard/branches_popover.cljs
+++ b/frontend/src/app/main/ui/dashboard/branches_popover.cljs
@@ -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))))}]]])]]]]))
diff --git a/frontend/src/app/main/ui/dashboard/branches_popover.scss b/frontend/src/app/main/ui/dashboard/branches_popover.scss
index 38de424df9..32f4796464 100644
--- a/frontend/src/app/main/ui/dashboard/branches_popover.scss
+++ b/frontend/src/app/main/ui/dashboard/branches_popover.scss
@@ -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);
-}
diff --git a/frontend/src/app/main/ui/dashboard/comments.scss b/frontend/src/app/main/ui/dashboard/comments.scss
index bfbb1d38fa..0e7cd075ef 100644
--- a/frontend/src/app/main/ui/dashboard/comments.scss
+++ b/frontend/src/app/main/ui/dashboard/comments.scss
@@ -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;
diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs
index fc5fa9efa7..7a0abddea5 100644
--- a/frontend/src/app/main/ui/dashboard/file_menu.cljs
+++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs
@@ -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))
diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss
index 5b7c8cf9b1..f0463f4496 100644
--- a/frontend/src/app/main/ui/dashboard/grid.scss
+++ b/frontend/src/app/main/ui/dashboard/grid.scss
@@ -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);
}
diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs
index 5802dd2dde..f65ed4f5ce 100644
--- a/frontend/src/app/main/ui/ds.cljs
+++ b/frontend/src/app/main/ui/ds.cljs
@@ -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*
diff --git a/frontend/src/app/main/ui/ds/colors.scss b/frontend/src/app/main/ui/ds/colors.scss
index eb23025b97..aec1a8b4c3 100644
--- a/frontend/src/app/main/ui/ds/colors.scss
+++ b/frontend/src/app/main/ui/ds/colors.scss
@@ -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};
diff --git a/frontend/src/app/main/ui/ds/product/branch_chip.cljs b/frontend/src/app/main/ui/ds/product/branch_chip.cljs
new file mode 100644
index 0000000000..aa09eb2787
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_chip.cljs
@@ -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)}]]))
diff --git a/frontend/src/app/main/ui/ds/product/branch_chip.mdx b/frontend/src/app/main/ui/ds/product/branch_chip.mdx
new file mode 100644
index 0000000000..b36c3e0c0b
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_chip.mdx
@@ -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";
+
+
+
+# BranchChip
+
+The button that names the branch the open file is and opens the branch
+switcher.
+
+
+
+## Variants
+
+`is-open` draws the chip pressed while its popover is shown.
+
+
+
+Long names are truncated with an ellipsis; the chip never grows past its
+container.
+
+
+
+## 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.
diff --git a/frontend/src/app/main/ui/ds/product/branch_chip.scss b/frontend/src/app/main/ui/ds/product/branch_chip.scss
new file mode 100644
index 0000000000..188828c81b
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_chip.scss
@@ -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;
+}
diff --git a/frontend/src/app/main/ui/ds/product/branch_chip.stories.jsx b/frontend/src/app/main/ui/ds/product/branch_chip.stories.jsx
new file mode 100644
index 0000000000..673d63d764
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_chip.stories.jsx
@@ -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 }) => ,
+};
+
+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 }) => (
+
+
+
+ ),
+};
diff --git a/frontend/src/app/main/ui/ds/product/branch_pill.cljs b/frontend/src/app/main/ui/ds/product/branch_pill.cljs
new file mode 100644
index 0000000000..f8c0425872
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_pill.cljs
@@ -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]]))
diff --git a/frontend/src/app/main/ui/ds/product/branch_pill.mdx b/frontend/src/app/main/ui/ds/product/branch_pill.mdx
new file mode 100644
index 0000000000..07c2dc6e2f
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_pill.mdx
@@ -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";
+
+
+
+# BranchPill
+
+A branch name inside running text, drawn as a static pill so it reads
+apart from the words around it.
+
+
+
+## Variants
+
+`is-main` draws main muted, with the commit icon.
+
+
+
+`is-review` draws the branch in the info blue that marks pull requests
+and design reviews. It has no effect on main.
+
+
+
+Two pills and an arrow name where a change goes.
+
+
+
+## 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`.
diff --git a/frontend/src/app/main/ui/ds/product/branch_pill.scss b/frontend/src/app/main/ui/ds/product/branch_pill.scss
new file mode 100644
index 0000000000..3443c47b37
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_pill.scss
@@ -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;
+}
diff --git a/frontend/src/app/main/ui/ds/product/branch_pill.stories.jsx b/frontend/src/app/main/ui/ds/product/branch_pill.stories.jsx
new file mode 100644
index 0000000000..df19ca3c73
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_pill.stories.jsx
@@ -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 }) => ,
+};
+
+export const Default = {};
+
+export const Main = {
+ args: {
+ label: "Main",
+ isMain: true,
+ },
+};
+
+export const Review = {
+ args: {
+ isReview: true,
+ },
+};
+
+export const Route = {
+ render: () => (
+
+
+ →
+
+
+ ),
+};
diff --git a/frontend/src/app/main/ui/ds/product/branch_row.cljs b/frontend/src/app/main/ui/ds/product/branch_row.cljs
new file mode 100644
index 0000000000..4e850fb875
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_row.cljs
@@ -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])]))
diff --git a/frontend/src/app/main/ui/ds/product/branch_row.mdx b/frontend/src/app/main/ui/ds/product/branch_row.mdx
new file mode 100644
index 0000000000..c4813c0ced
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_row.mdx
@@ -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";
+
+
+
+# BranchRow
+
+A branch in a short list, such as the branch switcher or the dashboard
+branches popover. Clicking it opens the branch.
+
+
+
+## Variants
+
+The branch the open file is shows a tick and cannot be opened again.
+
+
+
+Children go to the end of the row, usually a `BranchStatus`.
+
+
+
+## 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)}]]
+```
diff --git a/frontend/src/app/main/ui/ds/product/branch_row.scss b/frontend/src/app/main/ui/ds/product/branch_row.scss
new file mode 100644
index 0000000000..8b98a526dd
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_row.scss
@@ -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);
+}
diff --git a/frontend/src/app/main/ui/ds/product/branch_row.stories.jsx b/frontend/src/app/main/ui/ds/product/branch_row.stories.jsx
new file mode 100644
index 0000000000..d96a3c1472
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_row.stories.jsx
@@ -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 }) => (
+
+
+
+ ),
+};
+
+export const Default = {};
+
+export const Current = {
+ args: {
+ label: "feature-login",
+ isCurrent: true,
+ },
+};
+
+export const WithStatus = {
+ render: ({ ...args }) => (
+
+
+
+
+
+ ),
+};
diff --git a/frontend/src/app/main/ui/ds/product/branch_status.cljs b/frontend/src/app/main/ui/ds/product/branch_status.cljs
new file mode 100644
index 0000000000..03881cfbba
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_status.cljs
@@ -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))])]))
diff --git a/frontend/src/app/main/ui/ds/product/branch_status.mdx b/frontend/src/app/main/ui/ds/product/branch_status.mdx
new file mode 100644
index 0000000000..ab02b56b3f
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_status.mdx
@@ -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";
+
+
+
+# BranchStatus
+
+How a branch stands against main: the changes it has (↑) and lacks (↓),
+and one pill with what blocks or moves its merge.
+
+
+
+## Variants
+
+The pill shows the review state of an open pull request, pending or
+approved, with its approvals.
+
+
+
+
+
+Conflicts take the pill over the review state, because they block the
+merge whatever the reviewers say.
+
+
+
+When the comparison with main fails, a warning replaces the counts:
+zeros would read as "in sync".
+
+
+
+## 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.
diff --git a/frontend/src/app/main/ui/ds/product/branch_status.scss b/frontend/src/app/main/ui/ds/product/branch_status.scss
new file mode 100644
index 0000000000..1a5bcbe5fb
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_status.scss
@@ -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);
+}
diff --git a/frontend/src/app/main/ui/ds/product/branch_status.stories.jsx b/frontend/src/app/main/ui/ds/product/branch_status.stories.jsx
new file mode 100644
index 0000000000..d0ee46cf6e
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/branch_status.stories.jsx
@@ -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 }) => ,
+};
+
+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,
+ },
+};
diff --git a/frontend/src/app/main/ui/workspace/branch_switcher.cljs b/frontend/src/app/main/ui/workspace/branch_switcher.cljs
new file mode 100644
index 0000000000..15e1c027bb
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/branch_switcher.cljs
@@ -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}]]])))
diff --git a/frontend/src/app/main/ui/workspace/branch_switcher.scss b/frontend/src/app/main/ui/workspace/branch_switcher.scss
new file mode 100644
index 0000000000..ae206cc70b
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/branch_switcher.scss
@@ -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);
+}
diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs
index 74b6be4a4b..6bf506c805 100644
--- a/frontend/src/app/main/ui/workspace/left_header.cljs
+++ b/frontend/src/app/main/ui/workspace/left_header.cljs
@@ -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 " ()" 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}])]))
diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss
index aa37332394..2a72fd755a 100644
--- a/frontend/src/app/main/ui/workspace/left_header.scss
+++ b/frontend/src/app/main/ui/workspace/left_header.scss
@@ -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 {
diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs
index 042a679e67..e9a4e2b837 100644
--- a/frontend/src/app/main/ui/workspace/main_menu.cljs
+++ b/frontend/src/app/main/ui/workspace/main_menu.cljs
@@ -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))
diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss
index 591e96b9c7..8dffee3dc0 100644
--- a/frontend/src/app/main/ui/workspace/main_menu.scss
+++ b/frontend/src/app/main/ui/workspace/main_menu.scss
@@ -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);
-}
diff --git a/frontend/src/app/main/ui/workspace/menu_shortcuts.cljs b/frontend/src/app/main/ui/workspace/menu_shortcuts.cljs
new file mode 100644
index 0000000000..5497e38a4b
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/menu_shortcuts.cljs
@@ -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])]))
diff --git a/frontend/src/app/main/ui/workspace/menu_shortcuts.scss b/frontend/src/app/main/ui/workspace/menu_shortcuts.scss
new file mode 100644
index 0000000000..a60b43b2cf
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/menu_shortcuts.scss
@@ -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);
+}
diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs
index 0f136b1e8c..c8a0ece18e 100644
--- a/frontend/src/app/main/ui/workspace/sidebar.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar.cljs
@@ -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}]
diff --git a/frontend/src/app/main/ui/workspace/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar.scss
index 63d129a1ab..f2f2a815f6 100644
--- a/frontend/src/app/main/ui/workspace/sidebar.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar.scss
@@ -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);
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.scss b/frontend/src/app/main/ui/workspace/sidebar/assets.scss
index 716d0682b8..5a64f5341c 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets.scss
@@ -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;
diff --git a/frontend/src/app/main/ui/workspace/sidebar/branches.cljs b/frontend/src/app/main/ui/workspace/sidebar/branches.cljs
index 3c9e22fa5b..2f3d36809f 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/branches.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/branches.cljs
@@ -17,22 +17,25 @@
[app.main.refs :as refs]
[app.main.render :as render]
[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.checkbox :refer [checkbox*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.foundations.assets.icon :as i]
+ [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.layout.tab-switcher :refer [tab-switcher*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.ds.product.avatar :refer [avatar*]]
+ [app.main.ui.ds.product.branch-pill :refer [branch-pill*]]
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
+ [app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.hooks.resize :as r]
[app.main.ui.workspace.sidebar.pull-requests :as prui :refer [pr-state-badge*]]
[app.util.color :as uc]
[app.util.dom :as dom]
[app.util.globals :as globals]
- [app.util.i18n :refer [tr]]
+ [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[goog.events :as events]
@@ -242,56 +245,48 @@
(st/emit! (dwb/create-branch file-id (str/trim branch-name) (str/trim description))
(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-branch}]]
- [:div {:class (stl/css :modal-header-text)}
- [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.create.title")]
- [:span {:class (stl/css :modal-subtitle)}
- (tr "workspace.branches.create.subtitle" (or file-name ""))]]
- [:> button* {:variant "ghost"
- :icon i/close
- :aria-label (tr "labels.close")
- :on-click on-close}]]
+ [:> modal* {:is-open true
+ :on-open-change (prui/on-dismiss on-close)}
+ [:> prui/dialog-header* {:icon i/git-branch
+ :title (tr "workspace.branches.create.title")
+ :subtitle (tr "workspace.branches.create.subtitle" (or file-name ""))}]
- [:div {:class (stl/css :modal-content)}
- [:> input* {:label (tr "workspace.branches.create.name-label")
- :icon i/git-branch
- :placeholder (tr "workspace.branches.create.name-placeholder")
- :value branch-name
- :on-change on-name-change}]
+ [:> modal-content* {:class (stl/css :dialog-content)}
+ [:> input* {:label (tr "workspace.branches.create.name-label")
+ :icon i/git-branch
+ :placeholder (tr "workspace.branches.create.name-placeholder")
+ :value branch-name
+ :on-change on-name-change}]
- [:div {:class (stl/css :field)}
- [:label {:class (stl/css :field-label)}
- (tr "workspace.branches.create.description-label")]
- [:textarea {:class (stl/css :textarea)
- :value description
- :on-change on-description-change
- :rows 2}]]
+ [:div {:class (stl/css :field)}
+ [:label {:class (stl/css :field-label)}
+ (tr "workspace.branches.create.description-label")]
+ [:textarea {:class (stl/css :textarea)
+ :value description
+ :on-change on-description-change
+ :rows 2}]]
- [:div {:class (stl/css :field)}
- [:label {:class (stl/css :field-label)}
- (tr "workspace.branches.create.derive-from")]
- [:div {:class (stl/css :derive-from)}
- [:span {:class (stl/css :derive-from-dot)}]
- [:span {:class (stl/css :derive-from-name)}
- (tr "workspace.branches.create.derive-from-main")]
- [:span {:class (stl/css :derive-from-hint)}
- (tr "workspace.branches.create.derive-from-latest")]]]
+ [:div {:class (stl/css :field)}
+ [:label {:class (stl/css :field-label)}
+ (tr "workspace.branches.create.derive-from")]
+ [:div {:class (stl/css :derive-from)}
+ [:span {:class (stl/css :derive-from-dot)}]
+ [:span {:class (stl/css :derive-from-name)}
+ (tr "workspace.branches.create.derive-from-main")]
+ [:span {:class (stl/css :derive-from-hint)}
+ (tr "workspace.branches.create.derive-from-latest")]]]
- [:> context-notification* {:level :info :type :context}
- (tr "workspace.branches.create.info")]]
+ [:> context-notification* {:level :info :type :context}
+ (tr "workspace.branches.create.info")]]
- [:div {:class (stl/css :modal-footer)}
- [:> button* {:variant "secondary" :on-click on-close}
- (tr "labels.cancel")]
- [:> button* {:variant "primary"
- :icon i/git-branch-plus
- :disabled (not valid?)
- :on-click on-submit}
- (tr "workspace.branches.create.submit")]]]]))
+ [:> modal-footer* {}
+ [:> button* {:variant "secondary" :on-click on-close}
+ (tr "labels.cancel")]
+ [:> button* {:variant "primary"
+ :icon i/git-branch-plus
+ :disabled (not valid?)
+ :on-click on-submit}
+ (tr "workspace.branches.create.submit")]]]))
;; --- Preview limits dialog (modal)
;;
@@ -325,52 +320,45 @@
(mf/with-effect []
(st/emit! (dwb/fetch-branching-limits)))
- [:div {:class (stl/css :modal-overlay)}
- [:div {:class (stl/css :modal-container :limits-container)}
- [:div {:class (stl/css :modal-header)}
- [:div {:class (stl/css :modal-header-icon)}
- [:> i/icon* {:icon-id i/git-branch}]]
- [:div {:class (stl/css :modal-header-text)}
- [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.limits.title")]
- [:span {:class (stl/css :modal-subtitle)} (tr "workspace.branches.limits.subtitle")]]
- [:> button* {:variant "ghost"
- :icon i/close
- :aria-label (tr "labels.close")
- :on-click on-close}]]
+ [:> modal* {:is-open true
+ :on-open-change (prui/on-dismiss on-close)}
+ [:> prui/dialog-header* {:icon i/git-branch
+ :title (tr "workspace.branches.limits.title")
+ :subtitle (tr "workspace.branches.limits.subtitle")}]
- [:div {:class (stl/css :modal-content :limits-content)}
- [:div {:class (stl/css :limits-section)}
- [:h3 {:class (stl/css :limits-section-title)} (tr "workspace.branches.limits.size.title")]
- [:ul {:class (stl/css :limits-list)}
- (if (some? limits)
- [:*
- [:> limits-item* {:text (tr "workspace.branches.limits.size.shapes" (grouped (:max-shapes limits)))}]
- [:> limits-item* {:text (tr "workspace.branches.limits.size.pages" (grouped (:max-pages limits)))}]
- [:> limits-item* {:text (tr "workspace.branches.limits.size.changes" (grouped (:max-oplog-changes limits)))}]]
- [:> limits-item* {:text (tr "workspace.branches.limits.size.loading")}])]]
+ [:> modal-content* {:class (stl/css :dialog-content)}
+ [:div {:class (stl/css :limits-section)}
+ [:h3 {:class (stl/css :limits-section-title)} (tr "workspace.branches.limits.size.title")]
+ [:ul {:class (stl/css :limits-list)}
+ (if (some? limits)
+ [:*
+ [:> limits-item* {:text (tr "workspace.branches.limits.size.shapes" (grouped (:max-shapes limits)))}]
+ [:> limits-item* {:text (tr "workspace.branches.limits.size.pages" (grouped (:max-pages limits)))}]
+ [:> limits-item* {:text (tr "workspace.branches.limits.size.changes" (grouped (:max-oplog-changes limits)))}]]
+ [:> limits-item* {:text (tr "workspace.branches.limits.size.loading")}])]]
- [:div {:class (stl/css :limits-section)}
- [:h3 {:class (stl/css :limits-section-title)} (tr "workspace.branches.limits.scope.title")]
- [:ul {:class (stl/css :limits-list)}
- [:> limits-item* {:text (tr "workspace.branches.limits.scope.nested")}]
- [:> limits-item* {:text (tr "workspace.branches.limits.scope.libraries")}]
- [:> limits-item* {:text (tr "workspace.branches.limits.scope.comments")}]
- [:> limits-item* {:text (tr "workspace.branches.limits.scope.page-attrs")}]]]
+ [:div {:class (stl/css :limits-section)}
+ [:h3 {:class (stl/css :limits-section-title)} (tr "workspace.branches.limits.scope.title")]
+ [:ul {:class (stl/css :limits-list)}
+ [:> limits-item* {:text (tr "workspace.branches.limits.scope.nested")}]
+ [:> limits-item* {:text (tr "workspace.branches.limits.scope.libraries")}]
+ [:> limits-item* {:text (tr "workspace.branches.limits.scope.comments")}]
+ [:> limits-item* {:text (tr "workspace.branches.limits.scope.page-attrs")}]]]
- [:div {:class (stl/css :limits-section)}
- [:h3 {:class (stl/css :limits-section-title)} (tr "workspace.branches.limits.merge.title")]
- [:ul {:class (stl/css :limits-list)}
- [:> limits-item* {:text (tr "workspace.branches.limits.merge.whole-entities")}]
- [:> limits-item* {:text (tr "workspace.branches.limits.merge.no-partial")}]
- [:> limits-item* {:text (tr "workspace.branches.limits.merge.main-moved")}]
- [:> limits-item* {:text (tr "workspace.branches.limits.merge.base-missing")}]]]
+ [:div {:class (stl/css :limits-section)}
+ [:h3 {:class (stl/css :limits-section-title)} (tr "workspace.branches.limits.merge.title")]
+ [:ul {:class (stl/css :limits-list)}
+ [:> limits-item* {:text (tr "workspace.branches.limits.merge.whole-entities")}]
+ [:> limits-item* {:text (tr "workspace.branches.limits.merge.no-partial")}]
+ [:> limits-item* {:text (tr "workspace.branches.limits.merge.main-moved")}]
+ [:> limits-item* {:text (tr "workspace.branches.limits.merge.base-missing")}]]]
- [:> context-notification* {:level :info :type :context}
- (tr "workspace.branches.limits.exit")]]
+ [:> context-notification* {:level :info :type :context}
+ (tr "workspace.branches.limits.exit")]]
- [:div {:class (stl/css :modal-footer)}
- [:> button* {:variant "primary" :on-click on-close}
- (tr "labels.close")]]]]))
+ [:> modal-footer* {}
+ [:> button* {:variant "primary" :on-click on-close}
+ (tr "labels.close")]]]))
;; --- Merge confirmation dialog (modal)
@@ -406,40 +394,35 @@
(st/emit! (dwb/merge-branch branch {:keep-branch archive? :excluded excluded})
(modal/hide))))]
- [:div {:class (stl/css :modal-overlay)}
- [:div {:class (stl/css :modal-container)}
- [:div {:class (stl/css :modal-header)}
- [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.merge.confirm-title")]
- [:> button* {:variant "ghost"
- :icon i/close
- :aria-label (tr "labels.close")
- :on-click on-close}]]
+ [:> modal* {:is-open true
+ :on-open-change (prui/on-dismiss on-close)}
+ [:> modal-header* {:title (tr "workspace.branches.merge.confirm-title")}]
- [:div {:class (stl/css :modal-content)}
- [:p {:class (stl/css :modal-message)}
- (for [[idx part] (map-indexed vector message-parts)]
- (if (odd? idx)
- [:span {:key idx :class (stl/css :modal-message-emphasis)} part]
- [:span {:key idx} part]))]
+ [:> modal-content* {:class (stl/css :dialog-content)}
+ [:p {:class (stl/css :modal-message)}
+ (for [[idx part] (map-indexed vector message-parts)]
+ (if (odd? idx)
+ [:span {:key idx :class (stl/css :modal-message-emphasis)} part]
+ [:span {:key idx} part]))]
- [:> checkbox* {:id "merge-archive-branch"
- :label (tr "workspace.branches.merge.archive-label")
- :checked archive?
- :on-change on-toggle-archive}]
+ [:> checkbox* {:id "merge-archive-branch"
+ :label (tr "workspace.branches.merge.archive-label")
+ :checked archive?
+ :on-change on-toggle-archive}]
- (if archive?
- [:> context-notification* {:level :warning :type :context}
- (tr "workspace.branches.merge.archive-warning")]
- [:> context-notification* {:level :info :type :context}
- (tr "workspace.branches.merge.delete-info")])]
+ (if archive?
+ [:> context-notification* {:level :warning :type :context}
+ (tr "workspace.branches.merge.archive-warning")]
+ [:> context-notification* {:level :info :type :context}
+ (tr "workspace.branches.merge.delete-info")])]
- [:div {:class (stl/css :modal-footer)}
- [:> button* {:variant "secondary" :on-click on-close}
- (tr "labels.cancel")]
- [:> button* {:variant "primary"
- :icon i/git-merge
- :on-click on-submit}
- (tr "workspace.branches.merge.confirm-accept")]]]]))
+ [:> modal-footer* {}
+ [:> button* {:variant "secondary" :on-click on-close}
+ (tr "labels.cancel")]
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :on-click on-submit}
+ (tr "workspace.branches.merge.confirm-accept")]]]))
;; --- Confirmation for relevant branch actions (merge / update)
@@ -509,105 +492,98 @@
(reset! editing? false)))
on-compare (mf/use-fn (mf/deps branch)
#(modal/show! :branch-compare {:branch branch}))]
- [:div {:class (stl/css :modal-overlay)}
- [:div {:class (stl/css :modal-container :info-container)}
- [:div {:class (stl/css :modal-header)}
- [:div {:class (stl/css :modal-header-icon)}
- [:> i/icon* {:icon-id i/git-branch}]]
- [:div {:class (stl/css :modal-header-text)}
- [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.info.title")]
- [:span {:class (stl/css :modal-subtitle)} (:name branch)]]
- [:span {:class (stl/css-case :info-status true
- :status-merged merged?
- :status-archived (= "archived" status)
- :status-open (= "open" status))}
- (tr (case status
- "merged" "workspace.branches.info.status.merged"
- "archived" "workspace.branches.info.status.archived"
- "workspace.branches.info.status.open"))]
- [:> button* {:variant "ghost"
- :icon i/close
- :aria-label (tr "labels.close")
- :on-click on-close}]]
+ [:> modal* {:is-open true
+ :on-open-change (prui/on-dismiss on-close)}
+ [:> prui/dialog-header* {:icon i/git-branch
+ :title (tr "workspace.branches.info.title")
+ :subtitle (:name branch)}
+ [:span {:class (stl/css-case :info-status true
+ :status-merged merged?
+ :status-archived (= "archived" status)
+ :status-open (= "open" status))}
+ (tr (case status
+ "merged" "workspace.branches.info.status.merged"
+ "archived" "workspace.branches.info.status.archived"
+ "workspace.branches.info.status.open"))]]
- [:div {:class (stl/css :modal-content)}
- ;; description: shown read-only, editable inline by the author
- [:div {:class (stl/css :info-section)}
- [:div {:class (stl/css :info-section-head)}
- [:span {:class (stl/css :field-label)} (tr "workspace.branches.info.description")]
- (when (and author? (not (deref editing?)))
- [:> button* {:variant "ghost" :on-click on-edit} (tr "labels.edit")])]
- (if (deref editing?)
- [:div {:class (stl/css :info-desc-edit)}
- [:textarea {:class (stl/css :textarea)
- :value desc
- :on-change on-desc-change
- :rows 3
- :auto-focus true
- :placeholder (tr "workspace.branches.info.description-placeholder")}]
- [:div {:class (stl/css :info-desc-actions)}
- [:> button* {:variant "secondary" :on-click on-cancel} (tr "labels.cancel")]
- [:> button* {:variant "primary" :on-click on-save} (tr "labels.save")]]]
- (if (str/blank? (:description branch))
- [:p {:class (stl/css :info-desc-empty)} (tr "workspace.branches.info.description-empty")]
- [:p {:class (stl/css :info-desc)} (:description branch)]))]
+ [:> modal-content* {:class (stl/css :dialog-content)}
+ ;; description: shown read-only, editable inline by the author
+ [:div {:class (stl/css :info-section)}
+ [:div {:class (stl/css :info-section-head)}
+ [:span {:class (stl/css :field-label)} (tr "workspace.branches.info.description")]
+ (when (and author? (not (deref editing?)))
+ [:> button* {:variant "ghost" :on-click on-edit} (tr "labels.edit")])]
+ (if (deref editing?)
+ [:div {:class (stl/css :info-desc-edit)}
+ [:textarea {:class (stl/css :textarea)
+ :value desc
+ :on-change on-desc-change
+ :rows 3
+ :auto-focus true
+ :placeholder (tr "workspace.branches.info.description-placeholder")}]
+ [:div {:class (stl/css :info-desc-actions)}
+ [:> button* {:variant "secondary" :on-click on-cancel} (tr "labels.cancel")]
+ [:> button* {:variant "primary" :on-click on-save} (tr "labels.save")]]]
+ (if (str/blank? (:description branch))
+ [:p {:class (stl/css :info-desc-empty)} (tr "workspace.branches.info.description-empty")]
+ [:p {:class (stl/css :info-desc)} (:description branch)]))]
- ;; metadata
- [:dl {:class (stl/css :info-grid)}
- [:div {:class (stl/css :info-row)}
- [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.author")]
- [:dd {:class (stl/css :info-val)}
- (when author [:> avatar* {:profile author :variant "S"}])
- [:span (or (:fullname author) "—")]]]
+ ;; metadata
+ [:dl {:class (stl/css :info-grid)}
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.author")]
+ [:dd {:class (stl/css :info-val)}
+ (when author [:> avatar* {:profile author :variant "S"}])
+ [:span (or (:fullname author) "—")]]]
- [:div {:class (stl/css :info-row)}
- [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.derived-from")]
- [:dd {:class (stl/css :info-val)}
- [:> i/icon* {:icon-id i/git-commit-vertical :size "s"}]
- [:span (or (:source-name branch) (tr "workspace.branches.main"))]]]
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.derived-from")]
+ [:dd {:class (stl/css :info-val)}
+ [:> i/icon* {:icon-id i/git-commit-vertical :size "s"}]
+ [:span (or (:source-name branch) (tr "workspace.branches.main"))]]]
- (when (:created-at branch)
- [:div {:class (stl/css :info-row)}
- [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.created")]
- [:dd {:class (stl/css :info-val)}
- (-> (:created-at branch) ct/inst (ct/format-inst :localized-date-time))]])
+ (when (:created-at branch)
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.created")]
+ [:dd {:class (stl/css :info-val)}
+ (-> (:created-at branch) ct/inst (ct/format-inst :localized-date-time))]])
- (when (:updated-at branch)
- [:div {:class (stl/css :info-row)}
- [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.updated")]
- [:dd {:class (stl/css :info-val)}
- (some-> (:updated-at branch) ct/inst ct/timeago)]])
+ (when (:updated-at branch)
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.updated")]
+ [:dd {:class (stl/css :info-val)}
+ (some-> (:updated-at branch) ct/inst ct/timeago)]])
- (when-not archived?
- [:div {:class (stl/css :info-row)}
- [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.changes")]
- [:dd {:class (stl/css :info-val :info-counts)}
- (if (counts-error? branch)
- [:span {:class (stl/css :count-error)
- :title (tr "workspace.branches.counts.error")}
- [:> i/icon* {:icon-id i/triangle-alert :size "s"}]]
- [:*
- [:span {:class (stl/css :count-ahead)
- :title (tr "workspace.branches.counts.ahead")}
- [:> i/icon* {:icon-id i/arrow-up :size "s"}] (dm/str (:ahead branch 0))]
- [:span {:class (stl/css :count-behind)
- :title (tr "workspace.branches.counts.behind")}
- [:> i/icon* {:icon-id i/arrow-down :size "s"}] (dm/str (:behind branch 0))]
- (when (pos? (:conflicts branch 0))
- [:span {:class (stl/css :item-badge :badge-conflict)}
- (tr "workspace.branches.banner.conflicts" (dm/str (:conflicts branch)))])])]])
-
- (when (:merged-at branch)
- [:div {:class (stl/css :info-row)}
- [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.merged")]
- [:dd {:class (stl/css :info-val)}
- (-> (:merged-at branch) ct/inst (ct/format-inst :localized-date-time))]])]]
-
- [:div {:class (stl/css :modal-footer)}
(when-not archived?
- [:> button* {:variant "secondary" :icon i/switch :on-click on-compare}
- (tr "workspace.branches.compare")])
- [:> button* {:variant "primary" :on-click on-close} (tr "labels.close")]]]]))
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.changes")]
+ [:dd {:class (stl/css :info-val :info-counts)}
+ (if (counts-error? branch)
+ [:span {:class (stl/css :count-error)
+ :title (tr "workspace.branches.counts.error")}
+ [:> i/icon* {:icon-id i/triangle-alert :size "s"}]]
+ [:*
+ [:span {:class (stl/css-case :count true :has-changes (pos? (:ahead branch 0)))
+ :title (tr "workspace.branches.counts.ahead")}
+ [:> i/icon* {:icon-id i/arrow-up :size "s"}] (dm/str (:ahead branch 0))]
+ [:span {:class (stl/css-case :count true :is-behind true :has-changes (pos? (:behind branch 0)))
+ :title (tr "workspace.branches.counts.behind")}
+ [:> i/icon* {:icon-id i/arrow-down :size "s"}] (dm/str (:behind branch 0))]
+ (when (pos? (:conflicts branch 0))
+ [:span {:class (stl/css :item-badge :badge-conflict)}
+ (tr "workspace.branches.banner.conflicts" (dm/str (:conflicts branch)))])])]])
+
+ (when (:merged-at branch)
+ [:div {:class (stl/css :info-row)}
+ [:dt {:class (stl/css :info-key)} (tr "workspace.branches.info.merged")]
+ [:dd {:class (stl/css :info-val)}
+ (-> (:merged-at branch) ct/inst (ct/format-inst :localized-date-time))]])]]
+
+ [:> modal-footer* {}
+ (when-not archived?
+ [:> button* {:variant "secondary" :icon i/switch :on-click on-compare}
+ (tr "workspace.branches.compare")])
+ [:> button* {:variant "primary" :on-click on-close} (tr "labels.close")]]]))
;; --- Branch list entry
@@ -656,12 +632,11 @@
(on-set-menu false)
(confirm-update! entry)))
- on-open-menu
- (mf/use-fn (mf/deps on-set-menu)
+ on-toggle-menu
+ (mf/use-fn (mf/deps on-set-menu menu-open?)
(fn [event]
(dom/stop-propagation event)
- (on-set-menu true)))
- on-close-menu (mf/use-fn (mf/deps on-set-menu) #(on-set-menu false))
+ (on-set-menu (not menu-open?))))
on-info
(mf/use-fn
@@ -780,8 +755,7 @@
[:li {:class (stl/css-case :branch-entry true
:is-archived archived?
- :is-current current
- :is-menu-open menu-open?)
+ :is-current current)
:role "button"
:on-click (when-not current on-open)}
[:div {:class (stl/css :branch-entry-icon)}
@@ -823,235 +797,221 @@
:title (tr "workspace.branches.counts.error")}
[:> i/icon* {:icon-id i/triangle-alert :size "s"}]]
[:*
- [:span {:class (stl/css :count-ahead)
+ [:span {:class (stl/css-case :count true :has-changes (pos? (or ahead 0)))
:title (tr "workspace.branches.counts.ahead")}
[:> i/icon* {:icon-id i/arrow-up :size "s"}]
(dm/str ahead)]
- [:span {:class (stl/css :count-behind)
+ [:span {:class (stl/css-case :count true :is-behind true :has-changes (pos? (or behind 0)))
:title (tr "workspace.branches.counts.behind")}
[:> i/icon* {:icon-id i/arrow-down :size "s"}]
(dm/str behind)]])])
+ ;; the row opens the branch on click: clicks on the menu, its
+ ;; trigger and its items (portaled, but still in this React tree)
+ ;; must not reach it
(when-not main?
- [:> icon-button* {:variant "ghost"
- :icon i/menu
- :aria-label (tr "labels.options")
- :on-click on-open-menu}])
+ [:div {:class (stl/css :branch-entry-menu)
+ :on-click dom/stop-propagation}
+ [:> menu* {:is-open menu-open?
+ :on-open-change on-set-menu
+ :placement "bottom 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.branches.menu.info")]
+ (when-not archived?
+ [:> menu-item* {:id "compare" :on-action on-compare}
+ (tr "workspace.branches.compare")])
+ (when (and (not archived?) (pos? (or behind 0)))
+ [:> menu-item* {:id "update" :on-action on-update}
+ (tr "workspace.branches.update")])
+ ;; review lifecycle: a branch without an open pull request offers to
+ ;; request one; with it, the review actions take its place (the
+ ;; review either ends merging the branch or is cancelled, leaving
+ ;; the branch as a normal branch again)
+ (when (and (not archived?) (dwpr/enabled?) (nil? pr))
+ [:> menu-item* {:id "request-review" :on-action on-request-review}
+ (tr "workspace.pull-requests.menu.request-review")])
+ (when (some? pr)
+ [:> menu-item* {:id "open-review" :on-action on-open-review}
+ (tr "workspace.pull-requests.actions.open-review")])
+ (when (some? pr)
+ [:> menu-item* {:id "review-details" :on-action on-review-details}
+ (tr "workspace.pull-requests.menu.review-details")])
+ (when (and (some? pr) author?)
+ [:> menu-item* {:id "publish-review" :on-action on-publish-review}
+ (tr "workspace.pull-requests.menu.publish")])
+ (when (and (some? pr) can-merge?)
+ [:> menu-item* {:id "merge-review" :on-action on-merge-review}
+ (tr "workspace.pull-requests.actions.merge")])
+ (when (and (some? pr) (or author? admin?))
+ [:> menu-item* {:id "cancel-review" :on-action on-cancel-review}
+ (tr "workspace.pull-requests.actions.close")])
+ (when-not archived?
+ [:> menu-item* {:id "rename" :on-action on-start-rename}
+ (tr "labels.rename")])
+ [:> menu-item* {:id "archive" :on-action on-archive}
+ (tr (if archived?
+ "workspace.branches.menu.restore"
+ "workspace.branches.menu.archive"))]
+ ;; only an open branch can leave the branch engine: once merged or
+ ;; archived there is nothing left to turn into an ordinary file
+ (when-not archived?
+ [:> menu-item* {:id "materialize" :on-action on-materialize}
+ (tr "workspace.branches.materialize.action")])
+ [:> menu-item* {:id "delete" :on-action on-delete}
+ (tr "labels.delete")]]])]]))
- [:> dropdown-menu* {:show (and (not main?) menu-open?)
- :on-close on-close-menu
- :class (stl/css :branch-options-dropdown)}
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-info}
- (tr "workspace.branches.menu.info")]
- (when-not archived?
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-compare}
- (tr "workspace.branches.compare")])
- (when (and (not archived?) (pos? (or behind 0)))
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-update}
- (tr "workspace.branches.update")])
- ;; review lifecycle: a branch without an open pull request offers to
- ;; request one; with it, the review actions take its place (the
- ;; review either ends merging the branch or is cancelled, leaving
- ;; the branch as a normal branch again)
- (when (and (not archived?) (dwpr/enabled?) (nil? pr))
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-request-review}
- (tr "workspace.pull-requests.menu.request-review")])
- (when (some? pr)
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-open-review}
- (tr "workspace.pull-requests.actions.open-review")])
- (when (some? pr)
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-review-details}
- (tr "workspace.pull-requests.menu.review-details")])
- (when (and (some? pr) author?)
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-publish-review}
- (tr "workspace.pull-requests.menu.publish")])
- (when (and (some? pr) can-merge?)
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-merge-review}
- (tr "workspace.pull-requests.actions.merge")])
- (when (and (some? pr) (or author? admin?))
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-cancel-review}
- (tr "workspace.pull-requests.actions.close")])
- (when-not archived?
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-start-rename}
- (tr "labels.rename")])
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-archive}
- (tr (if archived?
- "workspace.branches.menu.restore"
- "workspace.branches.menu.archive"))]
- ;; only an open branch can leave the branch engine: once merged or
- ;; archived there is nothing left to turn into an ordinary file
- (when-not archived?
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-materialize}
- (tr "workspace.branches.materialize.action")])
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-delete}
- (tr "labels.delete")]]]]))
+;; --- All branches dialog (the full list, with each branch's options)
-;; --- Branches panel
+(defn- branches-for-tab
+ "The branch rows a tab of the all-branches dialog lists: the open ones,
+ the open ones the profile created, or the merged and archived ones."
+ [tab rows profile-id]
+ (case tab
+ "mine" (filter #(and (= "open" (:status %)) (= profile-id (:created-by %))) rows)
+ "archived" (remove #(= "open" (:status %)) rows)
+ (filter #(= "open" (:status %)) rows)))
-(mf/defc branches-toolbox*
+(mf/defc branches-list-dialog*
+ {::mf/register modal/components
+ ::mf/register-as :branches-list}
[]
- (let [profiles (mf/deref refs/profiles)
- profile (mf/deref refs/profile)
- file (mf/deref refs/file)
- branch-ctx (mf/deref branch-context)
+ (let [{:keys [data]} (mf/deref branches)
+ profiles (mf/deref refs/profiles)
+ profile (mf/deref refs/profile)
+ file (mf/deref refs/file)
+ branch-ctx (mf/deref branch-context)
+ prs (mf/deref refs/pull-requests)
+ profile-id (:id profile)
- {:keys [status data] :as _state}
- (mf/deref branches)
+ profiles (cond-> profiles
+ profile-id (assoc profile-id profile))
- ;; a pull request is a state OF its branch: index the open ones by
- ;; branch id so each entry can surface its review state inline
prs-by-branch
- (when (dwpr/enabled?)
- (->> (:data (mf/deref refs/pull-requests))
- (filter #(= "open" (:status %)))
- (map (juxt :file-branch-id identity))
- (into {})))
+ (mf/with-memo [prs]
+ (when (dwpr/enabled?)
+ (->> (:data prs)
+ (filter #(= "open" (:status %)))
+ (map (juxt :file-branch-id identity))
+ (into {}))))
- ;; make sure the current user resolves as an author (e.g. for the
- ;; synthetic Main entry and branches the user created)
- profiles (cond-> profiles
- (:id profile) (assoc (:id profile) profile))
+ tab* (mf/use-state "active")
+ tab (deref tab*)
+ filter* (mf/use-state "")
+ filter-v (deref filter*)
- ;; the branch (or main) the open file currently is
- current-entry
- (if branch-ctx
- branch-ctx
- {:name (tr "workspace.branches.main")
- :created-by (:id profile)
- :is-main true
- :status "open"
- :branch-file-id (:id file)})
+ ;; only one entry's options dropdown may be open at a time
+ open-menu* (mf/use-state nil)
+ open-menu (deref open-menu*)
+ set-menu (mf/use-fn (fn [k open?] (reset! open-menu* (when open? k))))
- filter* (mf/use-state "")
- filter-v (deref filter*)
-
- show-archived? (mf/use-state false)
-
- ;; only one entry's options dropdown may be open at a time; the open
- ;; entry's key lives here (in the parent) so opening one closes any
- ;; other. nil = none open.
- open-menu* (mf/use-state nil)
- open-menu (deref open-menu*)
- entry-key (fn [entry] (dm/str (or (:id entry) (:branch-file-id entry))))
- set-menu (mf/use-fn (fn [k open?] (reset! open-menu* (when open? k))))
-
- entries
+ matching
(mf/with-memo [data filter-v]
- (->> data
- (filter #(or (str/blank? filter-v)
- (str/includes? (str/lower (or (:name %) ""))
- (str/lower filter-v))))))
+ (filter #(or (str/blank? filter-v)
+ (str/includes? (str/lower (or (:name %) "")) (str/lower filter-v)))
+ data))
- ;; the current branch (if any) is shown in its own section; drop it
- ;; from the rest of the list
- open-entries (->> entries
- (filter #(= "open" (:status %)))
- (remove #(= (:id %) (:id branch-ctx))))
- archived-entries (->> entries
- (remove #(= "open" (:status %)))
- (remove #(= (:id %) (:id branch-ctx))))
+ tabs
+ (mf/with-memo [data profile-id]
+ (let [label (fn [text t] (dm/str text " (" (count (branches-for-tab t data profile-id)) ")"))]
+ [{:id "mine" :label (label (tr "workspace.branches.list.tab.mine") "mine")}
+ {:id "active" :label (label (tr "workspace.branches.list.tab.active") "active")}
+ {:id "archived" :label (label (tr "workspace.branches.archived") "archived")}]))
- ;; when the open file is a branch, offer Main as a switch target
- main-entry (when branch-ctx
- {:name (tr "workspace.branches.main")
- :is-main true
- :status "open"
- :branch-file-id (:source-file-id branch-ctx)})
+ rows (branches-for-tab tab matching profile-id)
- other-entries (cond->> open-entries
- main-entry (cons main-entry))
+ on-tab-change (mf/use-fn #(reset! tab* %))
+ on-filter-change (mf/use-fn #(reset! filter* (dom/get-target-val %)))
- on-filter-change
- (mf/use-fn #(reset! filter* (dom/get-target-val %)))
+ search
+ (mf/html
+ [:> input* {:variant "dense"
+ :icon i/search
+ :class (stl/css :branches-list-search)
+ :placeholder (tr "workspace.branches.search.placeholder")
+ :value filter-v
+ :on-change on-filter-change}])
- on-toggle-archived
- (mf/use-fn #(swap! show-archived? not))
+ on-open-change
+ (mf/use-fn
+ (fn [open?]
+ (when-not open?
+ (st/emit! (modal/hide)))))
+
+ on-show-limits
+ (mf/use-fn #(modal/show! :branch-limits {}))
on-create
(mf/use-fn
(mf/deps file)
(fn [_]
- (modal/show! :create-branch {:file-name (:name file)})))
-
- on-show-limits
- (mf/use-fn #(modal/show! :branch-limits {}))]
+ (modal/show! :create-branch {:file-name (:name file)})))]
(mf/with-effect []
(st/emit! (dwb/init-branches-state))
(when (dwpr/enabled?)
- (st/emit! (dwpr/init-pull-requests-state))))
+ (st/emit! (dwpr/fetch-pull-requests))))
- [:div {:class (stl/css :branches-toolbox)}
- [:div {:class (stl/css :branches-header)}
- [:> input* {:variant "dense"
- :icon i/search
- :placeholder (tr "workspace.branches.search.placeholder")
- :value filter-v
- :on-change on-filter-change}]
- [:> button* {:variant "primary"
- :icon i/git-branch-plus
- :on-click on-create}
- (tr "workspace.branches.new")]]
+ [:> modal* {:is-open true
+ :on-open-change on-open-change
+ :size "large"}
+ [:> modal-header* {:title (tr "workspace.branches.list.title" (or (:source-name branch-ctx) (:name file)))}]
+ [:> modal-content* {:class (stl/css :branches-list-content)}
+ [:> tab-switcher* {:tabs tabs
+ :selected tab
+ :on-change on-tab-change
+ :scrollable-panel true
+ :action-button-position "end"
+ :action-button search
+ :class (stl/css :branches-list-tabs)}
+ (if (empty? rows)
+ [:div {:class (stl/css :branches-empty)}
+ [:> empty-state* {:icon i/git-branch
+ :text (tr "workspace.branches.list.empty")}]]
+ [:ul {:class (stl/css :branches-entries :branches-list-entries)}
+ (for [entry rows]
+ (let [k (dm/str (:id entry))]
+ [:> branch-entry* {:key k
+ :entry entry
+ :profiles profiles
+ :current (= (:id entry) (:id branch-ctx))
+ :pr (get prs-by-branch (:id entry))
+ :menu-open? (= open-menu k)
+ :on-set-menu (partial set-menu k)}]))])]]
- (cond
- (= status :loading)
- [:div {:class (stl/css :branches-empty)}
- [:> empty-state* {:icon i/git-branch
- :text (tr "workspace.branches.loading")}]]
-
- :else
- [:div {:class (stl/css :branches-list)}
- ;; current branch (or main) the open file currently is
- [:div {:class (stl/css :branches-section-header)}
- [:span (tr "workspace.branches.section.current")]]
- [:ul {:class (stl/css :branches-entries)}
- (let [k (entry-key current-entry)]
- [:> branch-entry* {:entry current-entry
- :profiles profiles
- :current true
- :pr (get prs-by-branch (:id current-entry))
- :menu-open? (= open-menu k)
- :on-set-menu (partial set-menu k)}])]
-
- (when (seq other-entries)
- [:div {:class (stl/css :branches-section-header)}
- [:span (tr "workspace.branches.section.others")]
- [:span {:class (stl/css :branches-section-count)} (dm/str (count other-entries))]])
- (when (seq other-entries)
- [:ul {:class (stl/css :branches-entries)}
- (for [entry other-entries]
- (let [k (entry-key entry)]
- [:> branch-entry* {:key k
- :entry entry
- :profiles profiles
- :pr (get prs-by-branch (:id entry))
- :menu-open? (= open-menu k)
- :on-set-menu (partial set-menu k)}]))])
-
- (when (seq archived-entries)
- [:div {:class (stl/css :branches-archived)}
- [:button {:class (stl/css :branches-archived-toggle)
- :on-click on-toggle-archived}
- [:> i/icon* {:icon-id (if (deref show-archived?) i/arrow-down i/arrow-right) :size "s"}]
- [:span (tr "workspace.branches.archived")]
- [:span {:class (stl/css :branches-archived-count)} (dm/str (count archived-entries))]]
- (when (deref show-archived?)
- [:ul {:class (stl/css :branches-entries)}
- (for [entry archived-entries]
- (let [k (entry-key entry)]
- [:> branch-entry* {:key k
- :entry entry
- :profiles profiles
- :menu-open? (= open-menu k)
- :on-set-menu (partial set-menu k)}]))])])])
-
- ;; the preview's limits belong where the feature is used, not in the
- ;; release notes: a user who meets a refusal finds the reason here
- [:div {:class (stl/css :branches-footer)}
- [:button {:class (stl/css :branches-limits-link)
- :on-click on-show-limits}
- [:> i/icon* {:icon-id i/help :size "s"}]
- [:span (tr "workspace.branches.limits.link")]]]]))
+ ;; the preview's limits belong where the feature is used: a user who
+ ;; meets a refusal finds the reason here
+ [:> modal-footer* {:variant "split"
+ :start (mf/html
+ [:> button* {:variant "ghost"
+ :icon i/help
+ :on-click on-show-limits}
+ (tr "workspace.branches.limits.link")])
+ :end (let [;; branches only grow from main: on a
+ ;; branch the button stays in place,
+ ;; disabled, and says where branches
+ ;; come from
+ on-branch? (and (some? branch-ctx)
+ (= (:branch-file-id branch-ctx) (:id file)))
+ button (mf/html
+ [:> button* {:variant "primary"
+ :icon i/git-branch-plus
+ :disabled on-branch?
+ :on-click on-create}
+ (tr "workspace.branches.switcher.new")])]
+ (if on-branch?
+ (mf/html
+ [:> tooltip* {:content (tr "workspace.branches.switcher.new-from-main")
+ :id "new-branch-disabled-tooltip"
+ :tab-index 0}
+ button])
+ button))}]]))
;; --- Compare changes dialog (read-only 3-way diff)
@@ -1185,7 +1145,7 @@
(mf/defc branch-compare-dialog*
{::mf/register modal/components
::mf/register-as :branch-compare}
- [{:keys [branch]}]
+ [{:keys [branch is-review]}]
(let [{:keys [status diff selected direction excluded]} (mf/deref branch-diff)
direction (or direction :branch->main)
incoming? (= direction :main->branch)
@@ -1258,58 +1218,48 @@
(when (and (= status :loaded) (nil? selected) (seq items))
(st/emit! (dwb/select-diff-change 0))))
- [:div {:class (stl/css :compare-overlay)}
- [:div {:class (stl/css :compare-container)}
- [:div {:class (stl/css :compare-header)}
- [:div {:class (stl/css :compare-title-group)}
- [:div {:class (stl/css :compare-title-icon)}
- [:> i/icon* {:icon-id i/switch}]]
- [:div {:class (stl/css :compare-title-text)}
- [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.compare.title")]
- [:div {:class (stl/css :compare-breadcrumb)}
- (if incoming?
- [:*
- [:span {:class (stl/css :breadcrumb-main)}
- [:> i/icon* {:icon-id i/git-commit-vertical :size "s"}]
- (tr "workspace.branches.main")]
- [:button {:class (stl/css :breadcrumb-swap)
- :title (tr "workspace.branches.compare.swap")
- :aria-label (tr "workspace.branches.compare.swap")
- :on-click on-swap}
- [:> i/icon* {:icon-id i/arrow-long-right :size "s"}]]
- [:span {:class (stl/css :breadcrumb-branch)}
- [:> i/icon* {:icon-id i/git-branch :size "s"}]
- (:name branch)]]
- [:*
- [:span {:class (stl/css :breadcrumb-branch)}
- [:> i/icon* {:icon-id i/git-branch :size "s"}]
- (:name branch)]
- [:button {:class (stl/css :breadcrumb-swap)
- :title (tr "workspace.branches.compare.swap")
- :aria-label (tr "workspace.branches.compare.swap")
- :on-click on-swap}
- [:> i/icon* {:icon-id i/arrow-long-right :size "s"}]]
- [:span {:class (stl/css :breadcrumb-main)}
- [:> i/icon* {:icon-id i/git-commit-vertical :size "s"}]
- (tr "workspace.branches.main")]])]]]
+ [:> modal* {:is-open true
+ :on-open-change (prui/on-dismiss on-close)
+ :size "xlarge"}
+ [:> prui/dialog-header*
+ {:icon i/switch
+ :title (tr "workspace.branches.compare.title")
+ :subtitle
+ (mf/html
+ [:div {:class (stl/css :compare-breadcrumb)}
+ (if incoming?
+ [:*
+ [:> branch-pill* {:label (tr "workspace.branches.main") :is-main true}]
+ [:button {:class (stl/css :breadcrumb-swap)
+ :title (tr "workspace.branches.compare.swap")
+ :aria-label (tr "workspace.branches.compare.swap")
+ :on-click on-swap}
+ [:> i/icon* {:icon-id i/arrow-long-right :size "s"}]]
+ ;; opened from a pull request, the branch takes the review blue
+ [:> branch-pill* {:label (:name branch) :is-review (boolean is-review)}]]
+ [:*
+ ;; opened from a pull request, the branch takes the review blue
+ [:> branch-pill* {:label (:name branch) :is-review (boolean is-review)}]
+ [:button {:class (stl/css :breadcrumb-swap)
+ :title (tr "workspace.branches.compare.swap")
+ :aria-label (tr "workspace.branches.compare.swap")
+ :on-click on-swap}
+ [:> i/icon* {:icon-id i/arrow-long-right :size "s"}]]
+ [:> branch-pill* {:label (tr "workspace.branches.main") :is-main true}]])])}
- (when stats
- [:div {:class (stl/css :compare-stats)}
- [:span {:class (stl/css :stat-badge :badge-added)}
- (dm/str (:added stats) " " (tr "workspace.branches.compare.stats.added"))]
- [:span {:class (stl/css :stat-badge :badge-modified)}
- (dm/str (:modified stats) " " (tr "workspace.branches.compare.stats.modified"))]
- [:span {:class (stl/css :stat-badge :badge-deleted)}
- (dm/str (:deleted stats) " " (tr "workspace.branches.compare.stats.deleted"))]
- (when (pos? (:conflicts stats))
- [:span {:class (stl/css :stat-badge :badge-conflict)}
- (dm/str (:conflicts stats) " " (tr "workspace.branches.compare.stats.conflicts"))])])
-
- [:> icon-button* {:variant "ghost"
- :icon i/close
- :aria-label (tr "labels.close")
- :on-click on-close}]]
+ (when stats
+ [:div {:class (stl/css :compare-stats)}
+ [:span {:class (stl/css :stat-badge :badge-added)}
+ (dm/str (:added stats) " " (tr "workspace.branches.compare.stats.added"))]
+ [:span {:class (stl/css :stat-badge :badge-modified)}
+ (dm/str (:modified stats) " " (tr "workspace.branches.compare.stats.modified"))]
+ [:span {:class (stl/css :stat-badge :badge-deleted)}
+ (dm/str (:deleted stats) " " (tr "workspace.branches.compare.stats.deleted"))]
+ (when (pos? (:conflicts stats))
+ [:span {:class (stl/css :stat-badge :badge-conflict)}
+ (dm/str (:conflicts stats) " " (tr "workspace.branches.compare.stats.conflicts"))])])]
+ [:> modal-content* {:class (stl/css :compare-content)}
(cond
(= status :loading)
[:div {:class (stl/css :compare-empty)}
@@ -1374,52 +1324,58 @@
:on-toggle on-toggle}])]]))]]
[:div {:class (stl/css :compare-detail)}
- [:> branch-compare-detail* {:item sel-item}]]])
+ [:> branch-compare-detail* {:item sel-item}]]])]
- (when (= status :loaded)
- (let [conflicts (long (or (:conflicts stats) 0))
- conflicts? (pos? conflicts)
- total (long (+ (or (:added stats) 0)
- (or (:modified stats) 0)
- (or (:deleted stats) 0)))]
- [:div {:class (stl/css :compare-footer)}
- [:div {:class (stl/css :compare-footer-info)}
- [:> i/icon* {:icon-id i/info :size "s"}]
- [:span (tr "workspace.branches.compare.footer-changes" total)]
- (when (seq all-keys)
- [:span {:class (stl/css :compare-footer-selected)}
- (tr "workspace.branches.compare.footer-selected"
- (str kept-count) (str (count all-keys)))])
- (when conflicts?
- [:span {:class (stl/css :compare-footer-conflicts)}
- (dm/str " · " (tr "workspace.branches.compare.footer-conflicts" conflicts))])]
- [:div {:class (stl/css :compare-footer-actions)}
- [:> button* {:variant "secondary"
- :icon i/download
- :on-click on-export}
- (tr "workspace.branches.compare.export")]
- ;; one action that follows the current direction: update from main
- ;; while viewing main's incoming changes, merge otherwise
- (cond
- conflicts?
- [:> button* {:variant "primary"
- :icon i/git-merge
- :on-click on-resolve}
- (tr "workspace.branches.conflicts.resolve")]
+ (when (= status :loaded)
+ (let [conflicts (long (or (:conflicts stats) 0))
+ conflicts? (pos? conflicts)
+ total (long (+ (or (:added stats) 0)
+ (or (:modified stats) 0)
+ (or (:deleted stats) 0)))]
+ [:> modal-footer*
+ {:variant "split"
+ :class (stl/css :compare-footer)
+ :start
+ (mf/html
+ [:div {:class (stl/css :compare-footer-info)}
+ [:> i/icon* {:icon-id i/info :size "s"}]
+ [:span (tr "workspace.branches.compare.footer-changes" total)]
+ (when (seq all-keys)
+ [:span {:class (stl/css :compare-footer-selected)}
+ (tr "workspace.branches.compare.footer-selected"
+ (str kept-count) (str (count all-keys)))])
+ (when conflicts?
+ [:span {:class (stl/css :compare-footer-conflicts)}
+ (dm/str " · " (tr "workspace.branches.compare.footer-conflicts" conflicts))])])
+ :end
+ (mf/html
+ [:*
+ [:> button* {:variant "secondary"
+ :icon i/download
+ :on-click on-export}
+ (tr "workspace.branches.compare.export")]
+ ;; one action that follows the current direction: update from main
+ ;; while viewing main's incoming changes, merge otherwise
+ (cond
+ conflicts?
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :on-click on-resolve}
+ (tr "workspace.branches.conflicts.resolve")]
- incoming?
- [:> button* {:variant "primary"
- :icon i/status-update
- :disabled (zero? kept-count)
- :on-click on-update}
- (tr "workspace.branches.update")]
+ incoming?
+ [:> button* {:variant "primary"
+ :icon i/status-update
+ :disabled (zero? kept-count)
+ :on-click on-update}
+ (tr "workspace.branches.update")]
- :else
- [:> button* {:variant "primary"
- :icon i/git-merge
- :disabled (zero? kept-count)
- :on-click on-merge}
- (tr "workspace.branches.merge.action")])]]))]]))
+ :else
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :disabled (zero? kept-count)
+ :on-click on-merge}
+ (tr "workspace.branches.merge.action")])])}]))]))
;; --- Resolve conflicts dialog
@@ -1696,23 +1652,19 @@
(st/emit! (dwb/fetch-branch-diff (:id branch)
(if (= mode :update) :main->branch :branch->main))))
- [:div {:class (stl/css :compare-overlay)}
- [:div {:class (stl/css :compare-container)}
- [:div {:class (stl/css :compare-header)}
- [:div {:class (stl/css :compare-title-group)}
- [:div {:class (stl/css :compare-title-icon :title-icon-warning)}
- [:> i/icon* {:icon-id i/triangle-alert}]]
- [:div {:class (stl/css :compare-title-text)}
- [:h2 {:class (stl/css :modal-title)} (tr "workspace.branches.conflicts.title")]
- [:span {:class (stl/css :compare-subtitle)}
- (tr "workspace.branches.conflicts.subtitle" (:name branch))]]]
- [:span {:class (stl/css :conflicts-progress)}
- (tr "workspace.branches.conflicts.progress" (str resolved) (str total))]
- [:> icon-button* {:variant "ghost"
- :icon i/close
- :aria-label (tr "labels.close")
- :on-click on-close}]]
+ [:> modal* {:is-open true
+ :on-open-change (prui/on-dismiss on-close)
+ :size "xlarge"}
+ [:> prui/dialog-header* {:icon i/triangle-alert
+ :tone :warning
+ :title (tr "workspace.branches.conflicts.title")
+ :subtitle (tr "workspace.branches.conflicts.subtitle" (:name branch))}
+ [:span {:class (stl/css :conflicts-progress)}
+ (tr "workspace.branches.conflicts.progress" (str resolved) (str total))]]
+ ;; the toolbar sits inside the content, above the panes, so
+ ;; `modal*` keeps its three rows: header, content and footer
+ [:> modal-content* {:class (stl/css :compare-content)}
[:div {:class (stl/css :conflicts-toolbar)}
[:span {:class (stl/css :bulk-label)} (tr "workspace.branches.conflicts.bulk-label")]
[:> button* {:variant "secondary" :icon i/git-branch :on-click on-all-main}
@@ -1796,33 +1748,39 @@
:attrs attr-keys}])]
[:p {:class (stl/css :conflict-whole-hint)}
- (tr "workspace.branches.conflicts.whole-hint")])]))]])
+ (tr "workspace.branches.conflicts.whole-hint")])]))]])]
- [:div {:class (stl/css :compare-footer)}
- [:div {:class (stl/css :compare-footer-info)}
- (if all-done?
- [:*
- [:> i/icon* {:icon-id i/tick :size "s"}]
- (tr "workspace.branches.conflicts.footer-ready")]
- [:*
- [:span {:class (stl/css :compare-footer-conflicts)}
- [:> i/icon* {:icon-id i/triangle-alert :size "s"}]]
- [:span {:class (stl/css :compare-footer-conflicts)}
- (tr "workspace.branches.conflicts.footer-pending" (str pending))]])]
- [:div {:class (stl/css :compare-footer-actions)}
- ;; applying a MERGE deletes the branch unless the user opts to
- ;; keep it archived — same choice the plain merge dialog offers
- (when (not= mode :update)
- [:> checkbox* {:id "conflicts-archive-branch"
- :label (tr "workspace.branches.merge.archive-label")
- :checked keep?
- :on-change on-toggle-keep}])
- [:> button* {:variant "secondary" :on-click on-close} (tr "labels.cancel")]
- [:> button* {:variant "primary"
- :icon i/git-merge
- :disabled (not all-done?)
- :on-click on-apply}
- (tr "workspace.branches.conflicts.apply")]]]]]))
+ [:> modal-footer*
+ {:variant "split"
+ :class (stl/css :compare-footer)
+ :start
+ (mf/html
+ [:div {:class (stl/css :compare-footer-info)}
+ (if all-done?
+ [:*
+ [:> i/icon* {:icon-id i/tick :size "s"}]
+ (tr "workspace.branches.conflicts.footer-ready")]
+ [:*
+ [:span {:class (stl/css :compare-footer-conflicts)}
+ [:> i/icon* {:icon-id i/triangle-alert :size "s"}]]
+ [:span {:class (stl/css :compare-footer-conflicts)}
+ (tr "workspace.branches.conflicts.footer-pending" (str pending))]])])
+ :end
+ (mf/html
+ [:*
+ ;; applying a MERGE deletes the branch unless the user opts to
+ ;; keep it archived — same choice the plain merge dialog offers
+ (when (not= mode :update)
+ [:> checkbox* {:id "conflicts-archive-branch"
+ :label (tr "workspace.branches.merge.archive-label")
+ :checked keep?
+ :on-change on-toggle-keep}])
+ [:> button* {:variant "secondary" :on-click on-close} (tr "labels.cancel")]
+ [:> button* {:variant "primary"
+ :icon i/git-merge
+ :disabled (not all-done?)
+ :on-click on-apply}
+ (tr "workspace.branches.conflicts.apply")]])}]]))
;; --- Branch context banner (shown while editing a branch)
@@ -1843,6 +1801,52 @@
"--branch-banner-clearance"
(dm/str (max 0 room) "px"))))))
+(defn- banner-status
+ "What the bottom banner says about the open branch, most pressing
+ first and in the order of its primary action: merged, conflicts, main
+ moved, an open review, branch work."
+ [ctx pr]
+ (let [ahead (or (:ahead ctx) 0)
+ behind (or (:behind ctx) 0)
+ conflicts (or (:conflicts ctx) 0)]
+ (cond
+ (contains? #{"merged" "archived"} (:status ctx))
+ {:id :merged
+ :icon i/git-merge
+ :label (tr "workspace.branches.banner.merged")}
+
+ (counts-error? ctx)
+ {:id :counts-error
+ :icon i/triangle-alert
+ :label (tr "workspace.branches.counts.error")}
+
+ (pos? conflicts)
+ {:id :conflicts
+ :icon i/triangle-alert
+ :label (tr "workspace.branches.banner.status.conflicts" (i18n/c conflicts))}
+
+ (pos? behind)
+ {:id :behind
+ :icon i/arrow-down
+ :label (tr "workspace.branches.banner.status.behind" (i18n/c behind))}
+
+ (some? pr)
+ (let [[approvals total] (prui/current-approvals pr)]
+ {:id :review
+ :icon i/git-pull-request-arrow
+ :label (tr "workspace.branches.banner.status.in-review"
+ (dm/str approvals) (dm/str total))})
+
+ (pos? ahead)
+ {:id :ahead
+ :icon i/arrow-up
+ :label (tr "workspace.branches.banner.status.ahead" (i18n/c ahead))}
+
+ :else
+ {:id :up-to-date
+ :icon i/git-branch
+ :label (tr "workspace.branches.banner.status.up-to-date")})))
+
(mf/defc branch-context-banner*
[{:keys [file-id]}]
(let [ctx (mf/deref branch-context)
@@ -1866,15 +1870,10 @@
(= (:id ctx) (:file-branch-id %))))
(first)))
- 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))
+ 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-compare (mf/use-fn (mf/deps ctx) #(modal/show! :branch-compare {:branch ctx}))
on-update (mf/use-fn (mf/deps ctx) #(confirm-update! ctx))
@@ -1903,7 +1902,9 @@
;; colour/typography palette bar clear of it in both the collapsed and
;; the expanded state (see `publish-banner-clearance!`)
banner-ref (r/use-resize-observer
- (mf/use-fn (fn [_ _] (publish-banner-clearance!))))]
+ (mf/use-fn (fn [_ _] (publish-banner-clearance!))))
+
+ status (banner-status ctx open-pr)]
(mf/with-effect [file-id]
(when (contains? cf/flags :branching)
@@ -1953,16 +1954,15 @@
"workspace.branches.banner.collapse"))
:on-click on-toggle}
[:div {:class (stl/css :branch-banner-handle-btn)}]]
+ ;; the header row names the branch; the banner says where it
+ ;; stands and offers what to do next
[:div {:class (stl/css :branch-banner-content)}
- [:div {:class (stl/css :branch-banner-icon)}
- [:> i/icon* {:icon-id i/git-branch}]]
+ [:div {:class (stl/css-case :branch-banner-icon true
+ :branch-banner-icon-warning (contains? #{:conflicts :counts-error} (:id status)))}
+ [:> i/icon* {:icon-id (:icon status)}]]
[:div {:class (stl/css :branch-banner-text)}
- [:span {:class (stl/css :branch-banner-title)}
- (tr (if merged?
- "workspace.branches.banner.merged"
- "workspace.branches.banner.on-branch"))]
- [:span {:class (stl/css :branch-banner-name)} (:name ctx)]
- [:span {:class (stl/css :branch-banner-from)} (tr "workspace.branches.banner.from-main")]]
+ [:span {:class (stl/css :branch-banner-title)} (:label status)]]
+ [:div {:class (stl/css :branch-banner-separator)}]
(if merged?
[:> button* {:variant "secondary"
@@ -1972,23 +1972,6 @@
(let [conflicts (or (:conflicts ctx) 0)]
[:div {:class (stl/css :branch-banner-actions)}
- [:span {:class (stl/css :branch-banner-counts)}
- (if (counts-error? ctx)
- [:span {:class (stl/css :count-error)
- :title (tr "workspace.branches.counts.error")}
- [:> i/icon* {:icon-id i/triangle-alert :size "s"}]]
- [:*
- [:span {:class (stl/css :count-ahead)
- :title (tr "workspace.branches.counts.ahead")}
- [:> i/icon* {:icon-id i/arrow-up :size "s"}] (dm/str (:ahead ctx))]
- [:span {:class (stl/css :count-behind)
- :title (tr "workspace.branches.counts.behind")}
- [:> i/icon* {:icon-id i/arrow-down :size "s"}] (dm/str (:behind ctx))]])]
-
- (when (pos? conflicts)
- [:span {:class (stl/css :item-badge :badge-conflict)}
- (tr "workspace.branches.banner.conflicts" (dm/str conflicts))])
-
[:> button* {:variant "secondary"
:icon i/switch
:on-click on-compare}
@@ -2028,13 +2011,18 @@
(tr "workspace.branches.merge.action")])
(when prs-enabled?
+ ;; the banner sits at the bottom of the canvas: its menu
+ ;; opens upwards
[:div {:class (stl/css :branch-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 :branch-banner-options-dropdown)}
- [:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-merge}
+ [:> 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 "merge" :on-action on-merge}
(tr "workspace.pull-requests.actions.merge")]]])]))]])))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/branches.scss b/frontend/src/app/main/ui/workspace/sidebar/branches.scss
index a737f41aa1..41a3aa8715 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/branches.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar/branches.scss
@@ -4,59 +4,12 @@
//
// 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;
+@use "ds/_utils.scss" as *;
+@use "ds/typography.scss" as *;
-// All branch change-count numbers and changed-value indicators (counts,
-// ahead/behind, resolved/pending, property values) use the monospace
-// inspect font at 600 weight, for consistent, legible figures.
-@mixin branch-count {
- @include t.use-typography("code-font");
-
- font-weight: 600;
-}
-
-// --- Light-theme semantic tints
-//
-// The DS exposes the same lightened `--color-foreground-{success,error,warning,
-// info}` in both themes. That tint reads well over the dark tint backgrounds of
-// the default (dark) theme, but washes out over the pale pastel backgrounds the
-// light theme uses (see the success/info/error/warning chips and badges). For
-// this feature's surfaces we re-point those foregrounds to the more saturated
-// `--color-accent-*` tokens in the light theme only — reusing existing DS
-// variables, so there are no new variables, no global override and the dark
-// theme is untouched.
-.compare-overlay,
-.branches-toolbox,
-.branch-banner {
- :global(.light) & {
- --color-foreground-success: var(--color-accent-success);
- --color-foreground-error: var(--color-accent-error);
-
- // accent-warning is a bright orange that still washes out over the pale
- // warning background; darken it a touch (toward black) so it reads.
- --color-foreground-warning: color-mix(in srgb, var(--color-accent-warning) 60%, #000);
- --color-foreground-info: var(--color-accent-info);
- }
-}
-
-// --- Panel
-
-.branches-toolbox {
- display: grid;
- grid-template-rows: auto 1fr;
- gap: var(--sp-s);
- height: 100%;
- padding: var(--sp-s);
- overflow: hidden;
-}
-
-.branches-header {
- display: flex;
- align-items: center;
- gap: var(--sp-s);
-}
+// --- Branch lists
.branches-empty {
display: flex;
@@ -66,33 +19,6 @@
inline-size: $sz-200;
}
-.branches-list {
- display: flex;
- flex-direction: column;
- gap: var(--sp-xs);
- min-height: 0;
- overflow-y: auto;
-}
-
-.branches-section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- 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;
-}
-
-.branches-section-count {
- color: var(--color-foreground-secondary);
-}
-
.branches-entries {
display: flex;
flex-direction: column;
@@ -108,7 +34,7 @@
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s);
- border-radius: 8px;
+ border-radius: $br-8;
cursor: pointer;
&:hover {
@@ -120,9 +46,9 @@
display: flex;
align-items: center;
justify-content: center;
- inline-size: 32px;
- block-size: 32px;
- border-radius: 8px;
+ inline-size: $sz-32;
+ block-size: $sz-32;
+ border-radius: $br-8;
background-color: var(--color-background-tertiary);
color: var(--color-accent-primary);
}
@@ -131,11 +57,11 @@
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
- min-width: 0;
+ min-inline-size: 0;
}
.branch-entry-name {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
color: var(--color-foreground-primary);
white-space: nowrap;
@@ -147,11 +73,11 @@
display: flex;
align-items: center;
gap: var(--sp-xs);
- min-width: 0;
+ min-inline-size: 0;
}
.branch-entry-author {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-secondary);
white-space: nowrap;
@@ -167,7 +93,7 @@
}
.branch-pr-approvals {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
display: inline-flex;
align-items: center;
@@ -182,96 +108,56 @@
gap: var(--sp-xs);
}
+.branch-entry-menu {
+ display: flex;
+}
+
.branch-entry-counts {
display: flex;
align-items: center;
gap: var(--sp-s);
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
}
-.count-ahead,
-.count-behind {
- @include branch-count;
+// ahead/behind: a side with changes stands out, a zero stays quiet
+.count {
+ @include use-typography("code-font");
display: inline-flex;
align-items: center;
gap: var(--sp-xxs);
color: var(--color-foreground-secondary);
-}
-.count-ahead {
- color: var(--color-accent-primary);
+ &.has-changes {
+ color: var(--color-accent-primary);
+ }
+
+ // what main has and the branch lacks reads in the error color
+ &.is-behind.has-changes {
+ color: var(--color-foreground-error);
+ }
}
.count-error {
display: inline-flex;
align-items: center;
- color: var(--color-foreground-warning);
+ color: var(--color-accent-warning);
}
-// --- Create branch dialog
+// --- Dialogs: create, limits, merge and info
-.modal-overlay {
- @extend %modal-overlay-base;
-}
-
-.modal-container {
- @extend %modal-container-base;
-
- min-width: deprecated.$s-408;
-}
-
-.modal-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: var(--sp-s);
- margin-bottom: deprecated.$s-24;
-}
-
-.modal-header-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: $sz-32;
- height: $sz-32;
- border-radius: 8px;
- color: var(--color-accent-primary);
- background-color: var(--color-background-tertiary);
-}
-
-.modal-header-text {
+.dialog-content {
display: flex;
flex-direction: column;
- gap: var(--sp-xxs);
- flex: 1;
-}
-
-.modal-title {
- @include deprecated.headline-medium-typography;
-
- color: var(--modal-title-foreground-color);
-}
-
-.modal-subtitle {
- @include t.use-typography("body-small");
-
- color: var(--color-foreground-secondary);
-}
-
-.modal-content {
- @include deprecated.flex-column;
-
- gap: deprecated.$s-16;
- margin-bottom: deprecated.$s-24;
+ gap: var(--sp-l);
}
// Merge-dialog confirmation prompt: a regular-weight, secondary-color
// paragraph sitting between the title and the archive controls, with a
// touch more breathing room before the checkbox.
.modal-message {
- @include t.use-typography("body-large");
+ @include use-typography("body-large");
margin: 0;
margin-block-end: var(--sp-s);
@@ -290,22 +176,22 @@
}
.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");
}
.derive-from {
@@ -313,55 +199,42 @@
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s);
- border-radius: 8px;
+ border-radius: $br-8;
background-color: var(--color-background-tertiary);
}
.derive-from-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
+ inline-size: px2rem(8);
+ block-size: px2rem(8);
+ border-radius: $br-circle;
background-color: var(--color-accent-primary);
}
.derive-from-name {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
color: var(--color-foreground-primary);
- font-weight: 700;
}
.derive-from-hint {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-secondary);
}
-.modal-footer {
- display: flex;
- justify-content: flex-end;
- gap: var(--sp-s);
-}
-
// --- Branch info dialog
-.info-container {
- min-width: deprecated.$s-456;
-}
-
// status chip sits between the title block and the close button
.info-status {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
align-self: flex-start;
padding: var(--sp-xxs) var(--sp-s);
- border-radius: 12px;
- font-weight: 600;
- text-transform: capitalize;
+ border-radius: $br-12;
white-space: nowrap;
&.status-open {
- color: var(--color-foreground-info);
+ color: var(--color-accent-info);
background-color: var(--color-background-info);
}
@@ -387,15 +260,15 @@
align-items: center;
justify-content: space-between;
gap: var(--sp-s);
- min-block-size: 28px;
+ min-block-size: $sz-28;
}
.info-desc {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
margin: 0;
padding: var(--sp-s);
- border-radius: 8px;
+ border-radius: $br-8;
background-color: var(--color-background-tertiary);
color: var(--color-foreground-primary);
white-space: pre-wrap;
@@ -403,12 +276,12 @@
}
.info-desc-empty {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
margin: 0;
padding: var(--sp-s);
- border-radius: 8px;
- border: 1px dashed var(--color-background-quaternary);
+ border-radius: $br-8;
+ border: $b-1 dashed var(--color-background-quaternary);
color: var(--color-foreground-secondary);
font-style: italic;
}
@@ -430,39 +303,39 @@
flex-direction: column;
gap: 0;
margin: 0;
- border-radius: 8px;
- border: 1px solid var(--color-background-quaternary);
+ border-radius: $br-8;
+ border: $b-1 solid var(--color-background-quaternary);
overflow: hidden;
}
.info-row {
display: grid;
- grid-template-columns: minmax(120px, 0.5fr) 1fr;
+ grid-template-columns: minmax($sz-120, 0.5fr) 1fr;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s);
&:not(:last-child) {
- border-block-end: 1px solid var(--color-background-quaternary);
+ border-block-end: $b-1 solid var(--color-background-quaternary);
}
}
.info-key {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
margin: 0;
color: var(--color-foreground-secondary);
}
.info-val {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
display: flex;
align-items: center;
gap: var(--sp-xs);
margin: 0;
color: var(--color-foreground-primary);
- min-width: 0;
+ min-inline-size: 0;
}
.info-counts {
@@ -471,65 +344,13 @@
// --- Compare changes dialog
-.compare-overlay {
- @extend %modal-overlay-base;
-}
-
-.compare-container {
- @extend %modal-container-base;
- @include deprecated.menu-shadow;
+// The panes keep a fixed height whatever they list, as the dialog did
+// before `modal*`; each pane scrolls on its own.
+.compare-content {
+ --compare-panes-block-size: min(60vh, px2rem(520));
display: flex;
flex-direction: column;
- inline-size: min(95vw, 1000px);
- block-size: min(88vh, 700px);
- min-width: 0;
- min-height: 0;
- max-width: none;
- max-height: none;
- padding: deprecated.$s-16;
- overflow: hidden;
-}
-
-.compare-header {
- display: flex;
- align-items: center;
- gap: var(--sp-s);
- padding-bottom: var(--sp-s);
- border-bottom: 1px solid var(--color-background-quaternary);
-}
-
-.compare-title-group {
- display: flex;
- align-items: center;
- gap: var(--sp-s);
-}
-
-.compare-title-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- inline-size: 36px;
- block-size: 36px;
- border-radius: 8px;
- background-color: var(--color-background-tertiary);
- color: var(--color-foreground-secondary);
-}
-
-.compare-title-text {
- display: flex;
- flex-direction: column;
- gap: var(--sp-xxs);
-}
-
-.compare-subtitle {
- @include t.use-typography("body-small");
-
- color: var(--color-foreground-secondary);
-}
-
-.title-icon-warning {
- color: var(--color-foreground-warning);
}
.compare-breadcrumb {
@@ -537,42 +358,26 @@
align-items: center;
gap: var(--sp-xs);
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-secondary);
}
-.breadcrumb-branch {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-xxs);
- padding: var(--sp-xxs) var(--sp-s);
- border-radius: 8px;
- background-color: var(--color-background-tertiary);
- color: var(--color-accent-primary);
-}
-
-.breadcrumb-main {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-xxs);
- padding: var(--sp-xxs) var(--sp-s);
- border-radius: 8px;
- background-color: var(--color-background-tertiary);
- color: var(--color-foreground-secondary);
-}
-
.breadcrumb-swap {
- @include deprecated.button-style;
- @include deprecated.flex-center;
+ --breadcrumb-swap-foreground-color: var(--color-foreground-secondary);
+ display: flex;
+ justify-content: center;
+ align-items: center;
padding: var(--sp-xxs);
- border-radius: var(--sp-xs);
- color: var(--color-foreground-secondary);
+ border: none;
+ border-radius: $br-4;
+ background: none;
+ color: var(--breadcrumb-swap-foreground-color);
cursor: pointer;
&:hover {
- color: var(--color-accent-primary);
+ --breadcrumb-swap-foreground-color: var(--color-accent-primary);
}
}
@@ -585,20 +390,20 @@
.stat-badge,
.item-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-added {
- color: var(--color-foreground-success);
+ color: var(--color-accent-success);
background-color: var(--color-background-success);
}
.badge-modified {
- color: var(--color-foreground-info);
+ color: var(--color-accent-info);
background-color: var(--color-background-info);
}
@@ -608,28 +413,27 @@
}
.badge-conflict {
- color: var(--color-foreground-warning);
+ color: var(--color-accent-warning);
background-color: var(--color-background-warning);
}
.compare-empty {
display: flex;
- flex: 1;
align-items: center;
justify-content: center;
+ block-size: var(--compare-panes-block-size);
}
.compare-body {
display: grid;
- grid-template-columns: minmax(320px, 1fr) 2fr;
- flex: 1;
- min-height: 0;
+ grid-template-columns: minmax(px2rem(320), 1fr) 2fr;
+ block-size: var(--compare-panes-block-size);
}
.compare-side {
display: flex;
flex-direction: column;
- min-height: 0;
+ min-block-size: 0;
}
.compare-filters {
@@ -640,14 +444,14 @@
}
.filter-chip {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
display: inline-flex;
align-items: center;
gap: var(--sp-xs);
padding: var(--sp-xxs) var(--sp-s);
- border-radius: 16px;
- border: 1px solid transparent;
+ border-radius: $br-12;
+ border: $b-1 solid transparent;
background: none;
cursor: pointer;
color: var(--color-foreground-secondary);
@@ -664,17 +468,17 @@
}
.filter-dot {
- inline-size: 8px;
- block-size: 8px;
- border-radius: 50%;
+ inline-size: px2rem(8);
+ block-size: px2rem(8);
+ border-radius: $br-circle;
background-color: var(--color-foreground-secondary);
.dot-added & {
- background-color: var(--color-foreground-success);
+ background-color: var(--color-accent-success);
}
.dot-modified & {
- background-color: var(--color-foreground-info);
+ background-color: var(--color-accent-info);
}
.dot-deleted & {
@@ -687,7 +491,7 @@
flex: 1;
flex-direction: column;
gap: var(--sp-m);
- min-height: 0;
+ min-block-size: 0;
padding: 0 var(--sp-s) var(--sp-s);
overflow-y: auto;
}
@@ -699,7 +503,7 @@
}
.compare-group-head {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
display: flex;
align-items: center;
@@ -710,22 +514,21 @@
.compare-group-label {
flex: 1;
- font-weight: 600;
}
.compare-group-counts {
- @include branch-count;
+ @include use-typography("code-font");
display: inline-flex;
gap: var(--sp-xs);
}
.count-added {
- color: var(--color-foreground-success);
+ color: var(--color-accent-success);
}
.count-modified {
- color: var(--color-foreground-info);
+ color: var(--color-accent-info);
}
.count-deleted {
@@ -747,8 +550,8 @@
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s);
- border-radius: 8px;
- border-inline-start: 3px solid transparent;
+ border-radius: $br-8;
+ border-inline-start: $b-2 solid transparent;
background-color: var(--color-background-tertiary);
cursor: pointer;
@@ -762,11 +565,11 @@
}
&.status-added {
- border-inline-start-color: var(--color-foreground-success);
+ border-inline-start-color: var(--color-accent-success);
}
&.status-modified {
- border-inline-start-color: var(--color-foreground-info);
+ border-inline-start-color: var(--color-accent-info);
}
&.status-deleted {
@@ -774,7 +577,7 @@
}
&.status-conflict {
- border-inline-start-color: var(--color-foreground-warning);
+ border-inline-start-color: var(--color-accent-warning);
}
}
@@ -794,21 +597,20 @@
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
- min-width: 0;
+ min-inline-size: 0;
}
.compare-item-label {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
color: var(--color-foreground-primary);
- font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compare-item-subtitle {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-secondary);
white-space: nowrap;
@@ -817,10 +619,10 @@
}
.compare-detail {
- min-height: 0;
+ min-block-size: 0;
padding: var(--sp-l);
overflow-y: auto;
- border-inline-start: 1px solid var(--color-background-quaternary);
+ border-inline-start: $b-1 solid var(--color-background-quaternary);
}
.compare-detail-empty {
@@ -843,29 +645,20 @@
}
.compare-detail-title {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
margin: 0;
color: var(--color-foreground-primary);
- font-weight: 600;
}
.compare-detail-type {
- @include t.use-typography("body-small");
-
- color: var(--color-foreground-secondary);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.compare-detail-subtitle {
- @include t.use-typography("body-small");
+ @include use-typography("headline-small");
color: var(--color-foreground-secondary);
}
.compare-detail-hint {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
color: var(--color-foreground-secondary);
}
@@ -873,43 +666,43 @@
.prop-changes {
display: flex;
flex-direction: column;
- border: 1px solid var(--color-background-quaternary);
- border-radius: 12px;
+ border: $b-1 solid var(--color-background-quaternary);
+ border-radius: $br-12;
overflow: hidden;
}
.prop-changes-head {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-s) var(--sp-m);
- border-bottom: 1px solid var(--color-background-quaternary);
+ border-block-end: $b-1 solid var(--color-background-quaternary);
color: var(--color-foreground-primary);
}
.prop-changes-count {
- @include branch-count;
+ @include use-typography("code-font");
color: var(--color-foreground-secondary);
}
.prop-row {
display: grid;
- grid-template-columns: minmax(120px, 1fr) 2fr;
+ grid-template-columns: minmax($sz-120, 1fr) 2fr;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s) var(--sp-m);
- border-bottom: 1px solid var(--color-background-quaternary);
+ border-block-end: $b-1 solid var(--color-background-quaternary);
&:last-child {
- border-bottom: none;
+ border-block-end: none;
}
}
.prop-name {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
color: var(--color-foreground-secondary);
}
@@ -922,7 +715,7 @@
}
.value-chip {
- @include branch-count;
+ @include use-typography("code-font");
display: inline-flex;
align-items: center;
@@ -937,7 +730,7 @@
}
.value-branch {
- color: var(--color-foreground-success);
+ color: var(--color-accent-success);
background-color: var(--color-background-success);
}
@@ -947,23 +740,14 @@
}
.value-swatch {
- inline-size: 16px;
- block-size: 16px;
+ inline-size: $sz-16;
+ block-size: $sz-16;
border-radius: var(--sp-xs);
- border: 1px solid var(--color-background-quaternary);
-}
-
-.compare-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--sp-s);
- padding-top: var(--sp-s);
- border-top: 1px solid var(--color-background-quaternary);
+ border: $b-1 solid var(--color-background-quaternary);
}
.compare-footer-info {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
display: flex;
gap: var(--sp-xs);
@@ -971,28 +755,22 @@
}
.compare-footer-conflicts {
- color: var(--color-foreground-warning);
+ color: var(--color-accent-warning);
}
.compare-footer-selected {
color: var(--color-foreground-secondary);
}
-.compare-footer-actions {
- display: flex;
- align-items: center;
- gap: var(--sp-s);
-}
-
// --- Resolve conflicts dialog
.conflicts-progress {
- @include branch-count;
+ @include use-typography("code-font");
margin-inline-start: auto;
padding: var(--sp-xxs) var(--sp-s);
- border-radius: 12px;
- color: var(--color-foreground-info);
+ border-radius: $br-12;
+ color: var(--color-accent-info);
background-color: var(--color-background-info);
}
@@ -1001,20 +779,20 @@
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s) 0;
- border-bottom: 1px solid var(--color-background-quaternary);
+ border-block-end: $b-1 solid var(--color-background-quaternary);
}
.bulk-label {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-secondary);
}
.conflicts-pending {
- @include branch-count;
+ @include use-typography("code-font");
margin-inline-start: auto;
- color: var(--color-foreground-warning);
+ color: var(--color-accent-warning);
}
// left list
@@ -1023,17 +801,15 @@
display: flex;
flex-direction: column;
gap: var(--sp-xs);
- min-height: 0;
+ min-block-size: 0;
padding-block-start: var(--sp-s);
}
.conflicts-list-head {
- @include t.use-typography("body-small");
+ @include use-typography("headline-small");
padding-inline: var(--sp-s);
color: var(--color-foreground-secondary);
- font-weight: 600;
- text-transform: uppercase;
}
.conflict-item {
@@ -1042,8 +818,8 @@
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s);
- border-radius: 8px;
- border: 1px solid transparent;
+ border-radius: $br-8;
+ border: $b-1 solid transparent;
background-color: var(--color-background-tertiary);
cursor: pointer;
@@ -1058,7 +834,7 @@
// a selected-but-unresolved conflict gets the warning outline
&.is-selected.is-pending {
- border-color: var(--color-foreground-warning);
+ border-color: var(--color-accent-warning);
}
}
@@ -1073,21 +849,20 @@
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
- min-width: 0;
+ min-inline-size: 0;
}
.conflict-item-label {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
color: var(--color-foreground-primary);
- font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conflict-item-subtitle {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-secondary);
white-space: nowrap;
@@ -1096,19 +871,19 @@
}
.conflict-pill {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
display: inline-flex;
align-items: center;
gap: var(--sp-xs);
padding: var(--sp-xxs) var(--sp-s);
- border-radius: 12px;
+ border-radius: $br-12;
}
.pill-dot {
- inline-size: 6px;
- block-size: 6px;
- border-radius: 50%;
+ inline-size: $sz-6;
+ block-size: $sz-6;
+ border-radius: $br-circle;
background-color: currentcolor;
}
@@ -1123,12 +898,12 @@
}
.pill-mixed {
- color: var(--color-foreground-info);
+ color: var(--color-accent-info);
background-color: var(--color-background-info);
}
.pill-pending {
- color: var(--color-foreground-warning);
+ color: var(--color-accent-warning);
background-color: var(--color-background-warning);
}
@@ -1151,19 +926,18 @@
display: flex;
align-items: center;
justify-content: center;
- color: var(--color-foreground-warning);
+ color: var(--color-accent-warning);
}
.conflict-detail-title {
- @include t.use-typography("headline-small");
+ @include use-typography("headline-small");
margin: 0;
color: var(--color-foreground-primary);
- font-weight: 600;
}
.conflict-detail-subtitle {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-secondary);
}
@@ -1191,7 +965,7 @@
flex-direction: column;
align-items: center;
gap: var(--sp-xs);
- min-width: 0;
+ min-inline-size: 0;
&.is-result .preview-box {
border-color: var(--color-accent-primary);
@@ -1199,11 +973,9 @@
}
.preview-col-label {
- @include t.use-typography("body-small");
+ @include use-typography("headline-small");
color: var(--color-foreground-secondary);
- font-weight: 600;
- text-transform: uppercase;
.is-result & {
color: var(--color-accent-primary);
@@ -1211,7 +983,7 @@
}
.preview-col-sub {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-secondary);
white-space: nowrap;
@@ -1227,8 +999,8 @@
inline-size: 100%;
aspect-ratio: 1;
padding: var(--sp-s);
- border-radius: 8px;
- border: 2px solid var(--color-background-quaternary);
+ border-radius: $br-8;
+ border: $b-2 solid var(--color-background-quaternary);
background-color: var(--color-background-primary);
// checkerboard so transparency / light fills read clearly
@@ -1237,12 +1009,12 @@
linear-gradient(-45deg, var(--color-background-tertiary) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-background-tertiary) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-background-tertiary) 75%);
- background-size: 12px 12px;
+ background-size: px2rem(12) px2rem(12);
background-position:
0 0,
- 0 6px,
- 6px -6px,
- -6px 0;
+ 0 px2rem(6),
+ px2rem(6) px2rem(-6),
+ px2rem(-6) 0;
overflow: hidden;
}
@@ -1254,11 +1026,11 @@
.preview-swatch {
inline-size: 100%;
block-size: 100%;
- border-radius: 4px;
+ border-radius: $br-4;
}
.preview-type-sample {
- @include t.use-typography("headline-medium");
+ @include use-typography("headline-medium");
color: var(--color-foreground-primary);
line-height: 1;
@@ -1276,15 +1048,15 @@
.prop-table {
display: flex;
flex-direction: column;
- border-radius: 8px;
- border: 1px solid var(--color-background-quaternary);
+ border-radius: $br-8;
+ border: $b-1 solid var(--color-background-quaternary);
overflow: hidden;
}
.prop-table-head,
.prop-pick-row {
display: grid;
- grid-template-columns: minmax(120px, 1fr) 1fr 1fr;
+ grid-template-columns: minmax($sz-120, 1fr) 1fr 1fr;
align-items: center;
gap: var(--sp-s);
padding: var(--sp-s);
@@ -1295,32 +1067,29 @@
}
.prop-table-th {
- @include t.use-typography("body-small");
+ @include use-typography("headline-small");
color: var(--color-foreground-secondary);
- font-weight: 600;
- text-transform: uppercase;
}
.prop-pick-row {
- border-block-start: 1px solid var(--color-background-quaternary);
+ border-block-start: $b-1 solid var(--color-background-quaternary);
}
.prop-pick-name {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-primary);
}
.prop-pick {
- @include deprecated.button-style;
-
display: flex;
align-items: center;
justify-content: flex-start;
padding: var(--sp-xs);
- border-radius: 8px;
- border: 2px solid transparent;
+ border-radius: $br-8;
+ border: $b-2 solid transparent;
+ background: none;
cursor: pointer;
&:hover {
@@ -1334,7 +1103,7 @@
}
.conflict-whole-hint {
- @include t.use-typography("body-medium");
+ @include use-typography("body-medium");
color: var(--color-foreground-secondary);
}
@@ -1347,8 +1116,8 @@
inset-inline-start: 50%;
transform: translateX(-50%);
max-inline-size: 90vw;
- border-radius: deprecated.$s-8;
- border: deprecated.$s-2 solid var(--panel-border-color);
+ border-radius: $br-8;
+ border: $b-2 solid var(--color-background-quaternary);
background-color: var(--color-background-primary);
// The floating colour and typography palette bar hangs from the same bottom
@@ -1360,12 +1129,10 @@
// the bar follows the banner's collapsed and expanded states and the two
// cannot drift apart.
- // the kebab dropdown opens upwards, past 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;
+ // it must beat the viewport controls, which are raised to z-index 1
+ // while a drawing tool is active
+ // TODO: no DS z-index token for 2.
+ z-index: 2;
transition:
inset-block-end 0.2s,
block-size 0.2s;
@@ -1379,7 +1146,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;
.branch-banner-content {
block-size: 0;
@@ -1390,8 +1157,12 @@
}
.branch-banner-handle {
- @include deprecated.flex-center;
- @include deprecated.button-style;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border: none;
+ background: none;
+ cursor: pointer;
// overlaps the content's top padding so it stays snug without making the
// banner taller; fills the whole strip when collapsed
@@ -1406,10 +1177,10 @@
.branch-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);
}
.branch-banner-content {
@@ -1417,27 +1188,15 @@
align-items: center;
gap: var(--sp-m);
- // extra top room for the grip handle that overlaps it; no overflow
- // clip, the kebab dropdown opens upwards past the banner edge
+ // extra top room for the grip handle that overlaps it
padding: var(--sp-m) var(--sp-m) var(--sp-s);
}
.branch-banner-menu {
- position: relative;
display: flex;
align-items: center;
}
-// the banner sits at the bottom of the canvas, so its options menu
-// opens upwards
-.branch-banner-options-dropdown {
- @extend %menu-dropdown;
-
- inset-block-end: 100%;
- inset-inline-end: 0;
- inline-size: $sz-192;
-}
-
.branch-banner-icon {
display: flex;
align-items: center;
@@ -1453,17 +1212,22 @@
align-items: baseline;
gap: var(--sp-xs);
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
}
-.branch-banner-title,
-.branch-banner-from {
- color: var(--color-foreground-secondary);
-}
-
-.branch-banner-name {
+.branch-banner-title {
color: var(--color-foreground-primary);
- font-weight: 700;
+ white-space: nowrap;
+}
+
+.branch-banner-icon-warning {
+ color: var(--color-accent-warning);
+}
+
+.branch-banner-separator {
+ align-self: stretch;
+ inline-size: $sz-1;
+ background-color: var(--color-background-quaternary);
}
.branch-banner-actions {
@@ -1472,12 +1236,29 @@
gap: var(--sp-s);
}
-.branch-banner-counts {
- display: flex;
- align-items: center;
- gap: var(--sp-s);
+// --- All branches dialog
- @include t.use-typography("body-small");
+.branches-list-content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-m);
+ block-size: $sz-480;
+ min-block-size: 0;
+ padding: 0;
+}
+
+.branches-list-tabs {
+ flex: 1;
+ min-block-size: 0;
+}
+
+.branches-list-search {
+ inline-size: $sz-224;
+ margin-inline-start: auto;
+}
+
+.branches-list-entries {
+ padding-block-start: var(--sp-m);
}
// --- Lifecycle: kebab menu, rename, archived section
@@ -1487,108 +1268,19 @@
}
.branch-entry.is-current {
- border: 1px solid var(--color-background-quaternary);
+ border: $b-1 solid var(--color-background-quaternary);
background-color: var(--color-background-tertiary);
}
-// while its context menu is open, lift the whole entry (and its absolutely
-// positioned dropdown) above sibling entries so their hover/background does
-// not paint over the menu
-.branch-entry.is-menu-open {
- position: relative;
- z-index: 1;
-}
-
.branch-rename-input {
inline-size: 100%;
padding: var(--sp-xxs) var(--sp-xs);
- border-radius: 8px;
- border: 1px solid var(--color-accent-primary);
+ border-radius: $br-8;
+ border: $b-1 solid var(--color-accent-primary);
background-color: var(--color-background-tertiary);
color: var(--color-foreground-primary);
- @include t.use-typography("body-medium");
-}
-
-.branch-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;
-}
-
-.branches-archived {
- display: flex;
- flex-direction: column;
- gap: var(--sp-xs);
-}
-
-.branches-archived-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;
-}
-
-.branches-archived-count {
- margin-inline-start: auto;
-}
-
-.branches-footer {
- margin-block-start: auto;
- padding: var(--sp-s);
- border-block-start: 1px solid var(--color-background-quaternary);
-}
-
-// The limits dialog is the only long modal here, and `%modal-container-base`
-// caps its height without clipping, so a long list spills past the panel.
-// Make this one container a bounded flex column and give the scrolling to
-// the content, which needs `min-height: 0` to be allowed to shrink.
-.limits-container {
- display: flex;
- flex-direction: column;
- max-height: min(80vh, 42rem);
- overflow: hidden;
-}
-
-.limits-content {
- min-height: 0;
- flex: 1 1 auto;
- overflow-y: auto;
- padding-inline-end: var(--sp-xs);
-}
-
-.branches-limits-link {
- @include t.use-typography("body-small");
-
- display: flex;
- align-items: center;
- gap: var(--sp-xs);
- border: none;
- background: none;
- cursor: pointer;
- color: var(--color-foreground-secondary);
-
- &:hover {
- color: var(--color-accent-primary);
- }
+ @include use-typography("body-medium");
}
.limits-section {
@@ -1599,12 +1291,9 @@
}
.limits-section-title {
- @include t.use-typography("body-small");
+ @include use-typography("headline-small");
color: var(--color-foreground-secondary);
- text-transform: uppercase;
- letter-spacing: 0.04em;
- font-weight: 600;
}
.limits-list {
@@ -1617,7 +1306,7 @@
}
.limits-item {
- @include t.use-typography("body-small");
+ @include use-typography("body-small");
color: var(--color-foreground-primary);
}
diff --git a/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs
index 4ab66444ad..e511624e85 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs
@@ -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")])]]]]])))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/pull_requests.scss b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.scss
index d51f906f7a..de59e91089 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/pull_requests.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar/pull_requests.scss
@@ -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;
-}
diff --git a/frontend/test/frontend_tests/data/workspace_branches_test.cljs b/frontend/test/frontend_tests/data/workspace_branches_test.cljs
index d07bf69b8c..e3ae53b040 100644
--- a/frontend/test/frontend_tests/data/workspace_branches_test.cljs
+++ b/frontend/test/frontend_tests/data/workspace_branches_test.cljs
@@ -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))))))
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 74e2d8b5f6..3f7af349ea 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -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"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index 5715b6bba1..d0f920927f 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -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"