From 720b2f193d109cb985f175a27526e60966c7a2b7 Mon Sep 17 00:00:00 2001 From: Juan de la Cruz Date: Thu, 22 Oct 2026 16:50:19 +0200 Subject: [PATCH] :sparkles: Move branching into the workspace header (#12359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Move the branch switcher to the workspace header * :sparkles: Add branch DS components and the all-branches dialog * :lipstick: Polish the branch switcher and its menu entry * :sparkles: Keep the dashboard to switching branches * :lipstick: Polish the branch and pull request review UI * :recycle: Move the branching UI onto the design system * :bug: Address the review of the header branch switcher The bottom banner turned the nil counts that get-file-branch-info sends for a branch it cannot compare into zeros, so it said "Up to date with main". It now says "Couldn't compare this branch with main" in the warning colour, as the header row already did. During a review the review banner replaces the branch banner, which was the only component loading the branch context. The header row now loads it too, so a review link opened fresh shows the branch under review instead of "Main". On a branch, "New branch" in the all-branches dialog is disabled with a tooltip saying branches are created from main: the server refuses to branch a branch. With "Try interactions" gone, the create-review note says reviewers open the live branch and View mode plays it, and the unused open-pull-request-viewer event goes. The pull request specs follow the new banners, and three tests cover the fixes above. AI-assisted-by: claude-opus-5-5 Signed-off-by: Álvaro Tejero Cantero --------- Signed-off-by: Álvaro Tejero Cantero Co-authored-by: Álvaro Tejero Cantero --- .../get-file-branch-info-uncompared.json | 15 + .../playwright/ui/specs/pull-requests.spec.js | 76 +- .../src/app/main/data/workspace/branches.cljs | 97 +- .../main/data/workspace/pull_requests.cljs | 20 - .../app/main/data/workspace/shortcuts.cljs | 2 +- .../main/ui/dashboard/branches_popover.cljs | 53 +- .../main/ui/dashboard/branches_popover.scss | 89 +- .../src/app/main/ui/dashboard/comments.scss | 33 +- .../src/app/main/ui/dashboard/file_menu.cljs | 12 - frontend/src/app/main/ui/dashboard/grid.scss | 2 +- frontend/src/app/main/ui/ds.cljs | 8 + frontend/src/app/main/ui/ds/colors.scss | 6 - .../app/main/ui/ds/product/branch_chip.cljs | 45 + .../app/main/ui/ds/product/branch_chip.mdx | 51 + .../app/main/ui/ds/product/branch_chip.scss | 48 + .../ui/ds/product/branch_chip.stories.jsx | 53 + .../app/main/ui/ds/product/branch_pill.cljs | 43 + .../app/main/ui/ds/product/branch_pill.mdx | 53 + .../app/main/ui/ds/product/branch_pill.scss | 40 + .../ui/ds/product/branch_pill.stories.jsx | 57 + .../app/main/ui/ds/product/branch_row.cljs | 46 + .../src/app/main/ui/ds/product/branch_row.mdx | 39 + .../app/main/ui/ds/product/branch_row.scss | 64 + .../main/ui/ds/product/branch_row.stories.jsx | 51 + .../app/main/ui/ds/product/branch_status.cljs | 79 ++ .../app/main/ui/ds/product/branch_status.mdx | 56 + .../app/main/ui/ds/product/branch_status.scss | 64 + .../ui/ds/product/branch_status.stories.jsx | 82 ++ .../main/ui/workspace/branch_switcher.cljs | 309 +++++ .../main/ui/workspace/branch_switcher.scss | 121 ++ .../app/main/ui/workspace/left_header.cljs | 138 +- .../app/main/ui/workspace/left_header.scss | 44 +- .../src/app/main/ui/workspace/main_menu.cljs | 65 +- .../src/app/main/ui/workspace/main_menu.scss | 50 +- .../app/main/ui/workspace/menu_shortcuts.cljs | 27 + .../app/main/ui/workspace/menu_shortcuts.scss | 37 + .../src/app/main/ui/workspace/sidebar.cljs | 21 +- .../src/app/main/ui/workspace/sidebar.scss | 2 +- .../app/main/ui/workspace/sidebar/assets.scss | 2 +- .../main/ui/workspace/sidebar/branches.cljs | 1168 ++++++++--------- .../main/ui/workspace/sidebar/branches.scss | 773 ++++------- .../ui/workspace/sidebar/pull_requests.cljs | 490 +++---- .../ui/workspace/sidebar/pull_requests.scss | 411 ++---- .../data/workspace_branches_test.cljs | 92 ++ frontend/translations/en.po | 97 +- frontend/translations/es.po | 70 + 46 files changed, 3146 insertions(+), 2055 deletions(-) create mode 100644 frontend/playwright/data/pull-requests/get-file-branch-info-uncompared.json create mode 100644 frontend/src/app/main/ui/ds/product/branch_chip.cljs create mode 100644 frontend/src/app/main/ui/ds/product/branch_chip.mdx create mode 100644 frontend/src/app/main/ui/ds/product/branch_chip.scss create mode 100644 frontend/src/app/main/ui/ds/product/branch_chip.stories.jsx create mode 100644 frontend/src/app/main/ui/ds/product/branch_pill.cljs create mode 100644 frontend/src/app/main/ui/ds/product/branch_pill.mdx create mode 100644 frontend/src/app/main/ui/ds/product/branch_pill.scss create mode 100644 frontend/src/app/main/ui/ds/product/branch_pill.stories.jsx create mode 100644 frontend/src/app/main/ui/ds/product/branch_row.cljs create mode 100644 frontend/src/app/main/ui/ds/product/branch_row.mdx create mode 100644 frontend/src/app/main/ui/ds/product/branch_row.scss create mode 100644 frontend/src/app/main/ui/ds/product/branch_row.stories.jsx create mode 100644 frontend/src/app/main/ui/ds/product/branch_status.cljs create mode 100644 frontend/src/app/main/ui/ds/product/branch_status.mdx create mode 100644 frontend/src/app/main/ui/ds/product/branch_status.scss create mode 100644 frontend/src/app/main/ui/ds/product/branch_status.stories.jsx create mode 100644 frontend/src/app/main/ui/workspace/branch_switcher.cljs create mode 100644 frontend/src/app/main/ui/workspace/branch_switcher.scss create mode 100644 frontend/src/app/main/ui/workspace/menu_shortcuts.cljs create mode 100644 frontend/src/app/main/ui/workspace/menu_shortcuts.scss 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"