✨ Publish the preview's limits in the branch panel

Every refusal the branching commands can emit now has a line a user can
read: the three size gates name the limit they hit with the number the
backend sent, the scope refusals (no branch of a branch, a released merge
base, a change kind an update cannot replay, a branch whose state moved)
each get their own message, and a Branching limits dialog reachable from
the panel footer states them before anybody meets one. The three numbers
come from ::get-branching-limits, so the copy keeps no second copy of a
limit it could drift from.
This commit is contained in:
Álvaro Tejero Cantero 2026-08-14 08:53:24 +02:00
parent 264f1e930c
commit a6e7972297
No known key found for this signature in database
4 changed files with 330 additions and 13 deletions

View File

@ -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")))))))))))))

View File

@ -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)

View File

@ -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);
}

View File

@ -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…"