diff --git a/frontend/src/app/main/data/workspace/branches.cljs b/frontend/src/app/main/data/workspace/branches.cljs index 1a9a06c2a3..8ef3383e15 100644 --- a/frontend/src/app/main/data/workspace/branches.cljs +++ b/frontend/src/app/main/data/workspace/branches.cljs @@ -50,9 +50,11 @@ (defonce default-state {:status :loading - :data nil}) + :data nil + :limits nil}) (declare fetch-branches) +(declare fetch-branching-limits) (declare fetch-branch-context) (declare show-branches-panel) @@ -65,7 +67,8 @@ ptk/WatchEvent (watch [_ _ _] - (rx/of (fetch-branches))))) + (rx/of (fetch-branches) + (fetch-branching-limits))))) (defn update-branches-state [branch-state] @@ -91,6 +94,57 @@ (rx/catch (fn [_] (rx/of (update-branches-state {:status :loaded :data []}))))))))) +(defn fetch-branching-limits + "Load the size limits this instance refuses beyond, so the panel can + state them before a user meets one. The numbers live in the backend + config and are never restated here." + [] + (ptk/reify ::fetch-branching-limits + ptk/WatchEvent + (watch [_ state _] + (when (nil? (get-in state [:workspace-branches :limits])) + (->> (rp/cmd! :get-branching-limits {}) + (rx/map #(update-branches-state {:limits %})) + (rx/catch (fn [_] (rx/empty)))))))) + +(defn- refusal-notification + "The notification for a branching refusal, naming the limit it hit and + what to do instead, or `fallback` when the cause is not one. The number + comes from the refusal itself: the copy never keeps its own copy of a + limit, so it cannot drift from the config the gate reads." + [cause fallback] + (let [{:keys [code limit]} (ex-data cause)] + (case code + :branching-shape-limit-exceeded + (ntf/warn (tr "workspace.branches.limits.refuse-shapes" (str limit))) + + :branching-page-limit-exceeded + (ntf/warn (tr "workspace.branches.limits.refuse-pages" (str limit))) + + :branching-oplog-limit-exceeded + (ntf/warn (tr "workspace.branches.limits.refuse-changes" (str limit))) + + :cannot-branch-a-branch + (ntf/warn (tr "workspace.branches.limits.refuse-nested")) + + :base-snapshot-missing + (ntf/error (tr "workspace.branches.limits.refuse-base-missing")) + + ;; the branch changed state under an open panel: one line for the + ;; whole family, because what the user does next is the same + (:branch-not-found :branch-merged :branch-not-open) + (ntf/warn (tr "workspace.branches.limits.refuse-stale")) + + :invalid-branch-name + (ntf/warn (tr "workspace.branches.limits.refuse-name")) + + ;; the same refusal as the `:unsupported` merge status, raised while + ;; squashing an update, so it reads the same to a user + :unsupported-update-squash + (ntf/warn (tr "workspace.branches.update.unsupported")) + + (ntf/error fallback)))) + ;; --- Dashboard popover (list a file's branches from the dashboard) (defn- set-dashboard-branches @@ -124,7 +178,7 @@ (watch [_ _ _] (->> (rp/cmd! :update-file-branch {:id id :name name}) (rx/mapcat (fn [_] (rx/of (fetch-branches) (fetch-branch-context)))) - (rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error"))))))))) + (rx/catch (fn [cause] (rx/of (refusal-notification cause (tr "workspace.branches.lifecycle.error"))))))))) (defn set-branch-description "Update a branch's free-text description (shown in the Branch info modal). @@ -136,7 +190,7 @@ (watch [_ _ _] (->> (rp/cmd! :update-file-branch {:id id :description description}) (rx/mapcat (fn [_] (rx/of (fetch-branches) (fetch-branch-context)))) - (rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error"))))))))) + (rx/catch (fn [cause] (rx/of (refusal-notification cause (tr "workspace.branches.lifecycle.error"))))))))) (defn archive-branch ([id] (archive-branch id true)) @@ -147,7 +201,7 @@ (watch [_ _ _] (->> (rp/cmd! :archive-file-branch {:id id :archived archived?}) (rx/mapcat (fn [_] (rx/of (fetch-branches)))) - (rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error")))))))))) + (rx/catch (fn [cause] (rx/of (refusal-notification cause (tr "workspace.branches.lifecycle.error")))))))))) (defn delete-branch [id] @@ -158,7 +212,7 @@ (->> (rp/cmd! :delete-file-branch {:id id}) (rx/mapcat (fn [_] (rx/of (ntf/success (tr "workspace.branches.lifecycle.deleted")) (fetch-branches)))) - (rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error"))))))))) + (rx/catch (fn [cause] (rx/of (refusal-notification cause (tr "workspace.branches.lifecycle.error"))))))))) (defn create-branch "Create a branch from a file. With no `file-id`, branches the currently @@ -212,8 +266,8 @@ ;; from dashboard: jump into the new branch (rx/of (dcm/go-to-workspace :file-id branch-file-id)))))) (rx/catch - (fn [_] - (rx/of (ntf/error (tr "workspace.branches.create.error")))))))))))) + (fn [cause] + (rx/of (refusal-notification cause (tr "workspace.branches.create.error")))))))))))) (defn open-branch "Navigate to the branch file as a normal workspace file. Switching @@ -381,8 +435,8 @@ :conflicts (rx/of (modal/show :branch-conflicts {:branch branch :mode :update})) :unsupported (rx/of (ntf/warn (tr "workspace.branches.update.unsupported"))) (rx/of (ntf/error (tr "workspace.branches.update.error")))))) - (rx/catch (fn [_] - (rx/of (ntf/error (tr "workspace.branches.update.error"))))))))))) + (rx/catch (fn [cause] + (rx/of (refusal-notification cause (tr "workspace.branches.update.error"))))))))))) (defn set-conflict-resolution "Choose `:main` or `:branch` for a whole conflicting entity (by id). This @@ -477,5 +531,5 @@ ;; user resolves against the current state (rx/of (ntf/warn (tr "workspace.branches.merge.main-moved")) (fetch-branch-diff branch-id)) - (rx/of (ntf/error (tr "workspace.branches.merge.error"))))))))))))) + (rx/of (refusal-notification cause (tr "workspace.branches.merge.error"))))))))))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/branches.cljs b/frontend/src/app/main/ui/workspace/sidebar/branches.cljs index 46dd211634..59c0ec1f10 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/branches.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/branches.cljs @@ -316,6 +316,77 @@ :on-click on-submit} (tr "workspace.branches.create.submit")]]]])) +;; --- Preview limits dialog (modal) +;; +;; Every refusal the branching commands can emit has a line here, so a user +;; who meets one reads the feature as scoped rather than as broken. The three +;; numbers are fetched from the backend (`::get-branching-limits`) and never +;; restated in the copy: the config the gates read is their one home. + +(mf/defc limits-dialog* + {::mf/register modal/components + ::mf/register-as :branch-limits} + [] + (let [{:keys [limits]} (mf/deref branches) + + on-close + (mf/use-fn #(st/emit! (modal/hide))) + + item + (mf/use-fn + (fn [text] + [:li {:class (stl/css :limits-item) :key text} text]))] + + (mf/with-effect [] + (st/emit! (dwb/fetch-branching-limits))) + + [: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.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}]] + + [:div {:class (stl/css :modal-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) + [:* + (item (tr "workspace.branches.limits.size.shapes" (dm/str (:max-shapes limits)))) + (item (tr "workspace.branches.limits.size.pages" (dm/str (:max-pages limits)))) + (item (tr "workspace.branches.limits.size.changes" (dm/str (:max-oplog-changes limits))))] + (item (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)} + (item (tr "workspace.branches.limits.scope.nested")) + (item (tr "workspace.branches.limits.scope.libraries")) + (item (tr "workspace.branches.limits.scope.comments")) + (item (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)} + (item (tr "workspace.branches.limits.merge.whole-entities")) + (item (tr "workspace.branches.limits.merge.no-partial")) + (item (tr "workspace.branches.limits.merge.main-moved")) + (item (tr "workspace.branches.limits.merge.base-missing"))]] + + [:> 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")]]]])) + ;; --- Merge confirmation dialog (modal) (mf/defc merge-branch-dialog* @@ -863,7 +934,10 @@ (mf/use-fn (mf/deps file) (fn [_] - (modal/show! :create-branch {:file-name (:name file)})))] + (modal/show! :create-branch {:file-name (:name file)}))) + + on-show-limits + (mf/use-fn #(modal/show! :branch-limits {}))] (mf/with-effect [] (st/emit! (dwb/init-branches-state)) @@ -932,7 +1006,15 @@ :entry entry :profiles profiles :menu-open? (= open-menu k) - :on-set-menu (partial set-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")]]]])) ;; --- Compare changes dialog (read-only 3-way diff) diff --git a/frontend/src/app/main/ui/workspace/sidebar/branches.scss b/frontend/src/app/main/ui/workspace/sidebar/branches.scss index c5aebdd155..e2cb7c487a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/branches.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/branches.scss @@ -1522,3 +1522,56 @@ .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); +} + +.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); + } +} + +.limits-section { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin-block-end: var(--sp-m); +} + +.limits-section-title { + @include t.use-typography("body-small"); + + color: var(--color-foreground-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +.limits-list { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin: 0; + padding-inline-start: var(--sp-l); + list-style: disc; +} + +.limits-item { + @include t.use-typography("body-small"); + + color: var(--color-foreground-primary); +} diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e6b7c6ff2a..003509a586 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6809,6 +6809,134 @@ msgstr "Branch deleted" msgid "workspace.branches.lifecycle.error" msgstr "The branch operation could not be completed" +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.link" +msgstr "What branching can and can't do yet" + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.title" +msgstr "Branching limits" + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.subtitle" +msgstr "What this technology preview refuses, and what to do instead" + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.size.title" +msgstr "Size" + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.size.shapes" +msgstr "" +"A file with more than %s shapes can't be branched. Split the file, or keep " +"working on it without branches." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.size.pages" +msgstr "A file with more than %s pages can't be branched." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.size.changes" +msgstr "" +"A branch that has collected more than %s changes can't be compared or " +"merged. Merge it, or turn it into an ordinary file." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.size.loading" +msgstr "Loading this instance's limits…" + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.scope.title" +msgstr "Out of scope in this preview" + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.scope.nested" +msgstr "A branch can't be branched again." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.scope.libraries" +msgstr "" +"A branch covers one file. Changes a shared library makes elsewhere aren't " +"branched with it." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.scope.comments" +msgstr "Comments and comment threads stay on the file they were written on." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.scope.page-attrs" +msgstr "" +"A few page settings are reported as changed but not merged. The comparison " +"names them so nothing is lost silently." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.merge.title" +msgstr "What a merge won't decide for you" + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.merge.whole-entities" +msgstr "" +"A conflict is resolved one element at a time: you choose this branch's " +"version or main's." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.merge.no-partial" +msgstr "A merge takes the whole branch. There's no partial merge yet." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.merge.main-moved" +msgstr "" +"If main changes while you're resolving, the comparison is reloaded and you " +"review it again." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.merge.base-missing" +msgstr "" +"If the point this branch started from is no longer available, the branch can " +"still be turned into an ordinary file, but it can't be merged." + +#: src/app/main/ui/workspace/sidebar/branches.cljs +msgid "workspace.branches.limits.exit" +msgstr "" +"Any branch can become an ordinary file, keeping its content. Nothing you " +"make here depends on branching staying switched on." + +#: src/app/main/data/workspace/branches.cljs +msgid "workspace.branches.limits.refuse-shapes" +msgstr "" +"This file has more shapes than branching supports (%s). Split the file, or " +"keep working on it without branches." + +#: src/app/main/data/workspace/branches.cljs +msgid "workspace.branches.limits.refuse-pages" +msgstr "This file has more pages than branching supports (%s)." + +#: src/app/main/data/workspace/branches.cljs +msgid "workspace.branches.limits.refuse-changes" +msgstr "" +"This branch has collected more changes than a comparison supports (%s). " +"Merge it, or turn it into an ordinary file." + +#: src/app/main/data/workspace/branches.cljs +msgid "workspace.branches.limits.refuse-nested" +msgstr "A branch can't be branched again. Branch from main instead." + +#: src/app/main/data/workspace/branches.cljs +msgid "workspace.branches.limits.refuse-base-missing" +msgstr "" +"The point this branch started from is no longer available, so it can't be " +"compared or merged. It can still become an ordinary file." + +#: src/app/main/data/workspace/branches.cljs +msgid "workspace.branches.limits.refuse-stale" +msgstr "" +"This branch is no longer in the state the panel showed. Reopen the branches " +"panel to see where it is now." + +#: src/app/main/data/workspace/branches.cljs +msgid "workspace.branches.limits.refuse-name" +msgstr "A branch name can't be empty." + #: src/app/main/ui/workspace/sidebar/branches.cljs:799 msgid "workspace.branches.loading" msgstr "Loading branches…"