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"