Add check version menu entry

This commit is contained in:
Pablo Alba 2026-08-27 16:41:42 +02:00
parent 0e388442a1
commit 480de64083
8 changed files with 723 additions and 4 deletions

View File

@ -0,0 +1,329 @@
;; 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.dashboard.check-updates
(:require-macros [app.main.style :as stl])
(:require
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[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.util.dom :as dom]
[app.util.http :as http]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[clojure.string :as cstr]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private telemetry-origin
"check-updates-modal")
(def ^:private highlights-md-url
"https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/HIGHLIGHTS.md")
(def ^:private changelog-url
"https://github.com/penpot/penpot/blob/staging/CHANGES.md")
(def ^:private release-notes-url
"https://penpot.app/release-notes")
(def ^:private version-heading-re
#"(?m)^##\s+(\d+\.\d+\.\d+)(.*)$")
(def ^:private bullet-re
#"^- (.+)$")
(defn- unreleased-suffix?
[suffix]
(str/includes? (str/lower (or suffix "")) "unreleased"))
(defn- parse-section-items
[section]
(->> (cstr/split-lines section)
(keep (fn [line]
(when-let [[_ item] (re-matches bullet-re (str/trim line))]
item)))
vec))
(def ^:private version-triple-re
#"^(\d+)\.(\d+)\.(\d+)$")
(defn- version-triple
[version]
(when-let [[_ major minor patch] (re-matches version-triple-re version)]
[(js/parseInt major 10)
(js/parseInt minor 10)
(js/parseInt patch 10)]))
(defn version-compare
"Compare two X.Y.Z versions by major, minor, and patch."
[a b]
(let [[ma mi pa] (or (version-triple a) [0 0 0])
[mb mj pb] (or (version-triple b) [0 0 0])]
(or (when (not= ma mb) (- ma mb))
(when (not= mi mj) (- mi mj))
(- pa pb))))
(defn version-newer?
[a b]
(pos? (version-compare a b)))
(defn parse-highlights
"Parse HIGHLIGHTS.md into released version sections with bullet items.
Skips Unreleased headings. Preserves file order (newest first)."
[markdown]
(if-not (string? markdown)
[]
(->> (str/split markdown #"(?m)(?=^##\s+\d+\.\d+\.\d+)")
(keep (fn [part]
(when-let [[_ version suffix] (re-find version-heading-re part)]
(when-not (unreleased-suffix? suffix)
{:version version
:items (parse-section-items part)}))))
vec)))
(defn parse-latest-released-version
"Return the first non-unreleased `## X.Y.Z` heading from a highlights body."
[markdown]
(some-> (parse-highlights markdown) first :version))
(defn highlights-until-installed
"Keep released sections newer than the installed version (major, minor,
patch). Stops at the installed version or any older section."
[highlights installed]
(into []
(take-while #(version-newer? (:version %) installed))
highlights))
(defn- show-available-dialog
[{:keys [installed latest highlights]}]
(st/emit! (modal/show {:type :check-updates-available
:installed installed
:latest latest
:highlights highlights})))
(defn- show-uptodate-dialog
[version]
(st/emit! (modal/show {:type :check-updates-uptodate
:version version})))
(defn- show-unable-dialog
[]
(st/emit! (modal/show {:type :check-updates-unable})))
(defn- handle-highlights
[installed body]
(let [sections (parse-highlights body)
latest (some-> sections first :version)]
(cond
(nil? latest)
(show-unable-dialog)
(not (version-newer? latest installed))
(show-uptodate-dialog installed)
:else
(show-available-dialog
{:installed installed
:latest latest
:highlights (highlights-until-installed sections installed)}))))
(defn check-for-updates!
([current-version]
(check-for-updates! current-version nil))
([current-version {:keys [on-start on-finish]}]
(when on-start (on-start))
(->> (http/send! {:method :get
:mode :cors
:omit-default-headers true
:uri highlights-md-url
:response-type :text})
(rx/subs!
(fn [response]
(when on-finish (on-finish))
(if (http/success? response)
(handle-highlights current-version (:body response))
(show-unable-dialog)))
(fn [_cause]
(when on-finish (on-finish))
(show-unable-dialog))))))
(mf/defc check-updates-unable-modal*
{::mf/register modal/components
::mf/register-as :check-updates-unable}
[_]
(let [on-close
(mf/use-fn #(st/emit! (modal/hide)))
on-try-again
(mf/use-fn
(fn []
(st/emit! (modal/hide))
(check-for-updates! (:base cf/version))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:div {:class (stl/css :modal-title-row)}
[:> icon* {:icon-id i/msg-neutral
:class (stl/css :modal-title-icon)
:aria-hidden true}]
[:> heading* {:level 2
:typography "headline-medium"
:class (stl/css :modal-title)}
(tr "dashboard.check-updates.unable-title")]]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close
:class (stl/css :modal-close-btn)}]]
[:div {:class (stl/css :modal-content)}
[:> text* {:as "p"
:typography t/body-large
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.unable-message")]
[:> text* {:as "p"
:typography t/body-large
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.unable-hint")]]
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "primary"
:on-click on-try-again}
(tr "dashboard.check-updates.try-again")]]]]))
(mf/defc check-updates-uptodate-modal*
{::mf/register modal/components
::mf/register-as :check-updates-uptodate}
[{:keys [version]}]
(let [on-close
(mf/use-fn #(st/emit! (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-title-row)}
[:> icon* {:icon-id i/tick
:class (stl/css :modal-title-icon)
:aria-hidden true}]
[:> heading* {:level 2
:typography "headline-medium"
:class (stl/css :modal-title)}
(tr "dashboard.check-updates.uptodate-title")]]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close
:class (stl/css :modal-close-btn)}]]
[:div {:class (stl/css :modal-content)}
[:> text* {:as "p"
:typography t/body-large
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.uptodate-message")
" "
[:span {:class (stl/css :version)} version]]]
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "secondary"
:on-click on-close}
(tr "labels.close")]]]]))
(mf/defc check-updates-available-modal*
{::mf/register modal/components
::mf/register-as :check-updates-available}
[{:keys [installed latest highlights]}]
(let [on-close
(mf/use-fn #(st/emit! (modal/hide)))
on-changelog
(mf/use-fn
(mf/deps installed)
(fn []
(st/emit! (ev/event {::ev/name "explore-changelog-click"
::ev/origin telemetry-origin
:version installed}))
(dom/open-new-window changelog-url)))
on-release-notes
(mf/use-fn
(mf/deps installed)
(fn []
(st/emit! (ev/event {::ev/name "explore-product-updates-click"
::ev/origin telemetry-origin
:version installed}))
(dom/open-new-window release-notes-url)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container :modal-container-available)}
[:div {:class (stl/css :modal-header)}
[:div {:class (stl/css :modal-title-row)}
[:> icon* {:icon-id i/info
:class (stl/css :modal-title-icon)
:aria-hidden true}]
[:> heading* {:level 2
:typography "headline-medium"
:class (stl/css :modal-title)}
(tr "dashboard.check-updates.available-title")]]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close
:class (stl/css :modal-close-btn)}]]
[:div {:class (stl/css :version-bar)}
[:span {:class (stl/css :version-bar-side)}
(tr "dashboard.check-updates.installed-version")
" "
[:span {:class (stl/css :version)} installed]]
[:> icon* {:icon-id i/arrow-right
:class (stl/css :version-bar-arrow)
:size "s"
:aria-hidden true}]
[:span {:class (stl/css :version-bar-side)}
(tr "dashboard.check-updates.latest-version")
" "
[:span {:class (stl/css :version :version-accent)} latest]]]
[:div {:class (stl/css :modal-content)}
[:> text* {:as "p"
:typography t/body-large
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.available-message")]
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :highlights-title)}
(tr "dashboard.check-updates.highlights-title")]
[:div {:class (stl/css :highlights-scroll)}
(for [section highlights]
(let [version (:version section)
items (:items section)]
[:div {:key version
:class (stl/css :highlights-section)}
[:div {:class (stl/css :highlights-version)} version]
[:ul {:class (stl/css :highlights-list)}
(for [item items]
[:li {:key item
:class (stl/css :highlights-item)}
item])]]))]]
[:div {:class (stl/css :modal-footer :modal-footer-available)}
[:> button* {:variant "secondary"
:on-click on-changelog}
(tr "dashboard.check-updates.view-changelog")]
[:> button* {:variant "primary"
:on-click on-release-notes}
(tr "dashboard.check-updates.view-release-notes")]]]]))

View File

@ -0,0 +1,175 @@
// 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/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as *;
.modal-overlay {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
inset: 0;
z-index: var(--z-index-set);
background-color: var(--color-overlay-default);
}
.modal-container {
position: relative;
display: flex;
flex-direction: column;
gap: var(--sp-xxl);
padding: var(--sp-xxxl);
border-radius: $br-8;
background-color: var(--color-background-primary);
border: $b-2 solid var(--color-background-quaternary);
min-inline-size: $sz-364;
max-inline-size: $sz-512;
}
.modal-container-available {
max-inline-size: $sz-480;
max-block-size: min(90vh, $sz-712);
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-m);
padding-inline-end: var(--sp-xl);
}
.modal-title-row {
display: flex;
align-items: center;
gap: var(--sp-s);
}
.modal-title-icon {
flex-shrink: 0;
color: var(--color-foreground-primary);
}
.modal-title {
color: var(--color-foreground-primary);
}
.modal-close-btn {
position: absolute;
inset-block-start: var(--sp-m);
inset-inline-end: var(--sp-m);
}
.version-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-m);
padding: var(--sp-m) var(--sp-l);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
color: var(--color-foreground-secondary);
}
.version-bar-side {
@include use-typography("body-large");
}
.version-bar-arrow {
flex-shrink: 0;
color: var(--color-foreground-secondary);
}
.modal-content {
display: flex;
flex-direction: column;
gap: var(--sp-s);
min-block-size: 0;
}
.modal-msg {
margin: 0;
color: var(--color-foreground-secondary);
}
.version {
color: var(--color-foreground-primary);
font-weight: 700;
}
.version-accent {
color: var(--color-accent-primary);
}
.highlights-title {
margin: var(--sp-m) 0 0;
color: var(--color-foreground-secondary);
text-transform: uppercase;
}
.highlights-scroll {
display: flex;
flex-direction: column;
gap: var(--sp-l);
margin-block-start: var(--sp-s);
padding-inline-end: var(--sp-s);
max-block-size: $sz-284;
overflow-y: auto;
}
.highlights-section {
display: flex;
flex-direction: column;
gap: var(--sp-s);
}
.highlights-version {
@include use-typography("headline-small");
color: var(--color-foreground-primary);
font-weight: 700;
}
.highlights-list {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
margin: 0;
padding: 0;
list-style: none;
}
.highlights-item {
@include use-typography("body-large");
position: relative;
padding-inline-start: var(--sp-l);
color: var(--color-foreground-secondary);
&::before {
content: "";
position: absolute;
inset-block-start: 0.55em;
inset-inline-start: 0;
inline-size: $sz-6;
block-size: $sz-6;
border-radius: $br-circle;
background-color: var(--color-accent-primary);
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--sp-s);
}
.modal-footer-available {
flex-shrink: 0;
}

View File

@ -26,6 +26,7 @@
dropdown-menu-item*]]
[app.main.ui.components.link :refer [link*]]
[app.main.ui.components.organization-avatar :refer [organization-avatar*]]
[app.main.ui.dashboard.check-updates :as dcu]
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
@ -1256,8 +1257,11 @@
(mf/defc about-penpot-menu*
{::mf/private true}
[{:keys [on-close on-pointer-enter on-pointer-leave]}]
(let [version cf/version
[{:keys [on-close on-close-profile on-pointer-enter on-pointer-leave]}]
(let [version cf/version
checking* (mf/use-state false)
checking? (deref checking*)
show-release-notes
(mf/use-fn
(fn [event]
@ -1275,7 +1279,22 @@
(dom/get-data "eventname"))]
(st/emit! (ev/event {::ev/name eventname
::ev/origin "menu:in-app"}))
(dom/open-new-window url))))]
(dom/open-new-window url))))
check-for-updates
(mf/use-fn
(mf/deps on-close-profile version)
(fn [event]
(dom/stop-propagation event)
(when-not @checking*
(st/emit! (ev/event {::ev/name "check-for-updates"
::ev/origin "menu:in-app"
:version (:base version)}))
(dcu/check-for-updates!
(:base version)
{:on-start #(reset! checking* true)
:on-finish #(do (reset! checking* false)
(on-close-profile))}))))]
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :about)
@ -1297,7 +1316,22 @@
:data-url "https://penpot.app/terms"
:on-click handle-click-url
:data-eventname "explore-terms-service-click"}
(tr "auth.terms-of-service")]]))
(tr "auth.terms-of-service")]
(when-not (contains? cf/flags :air-gapped-conf)
[:*
[:hr {:role "separator" :class (stl/css :submenu-separator)}]
[:> dropdown-menu-item* {:class (stl/css-case :submenu-item true
:checking checking?)
:aria-disabled checking?
:can-focus (not checking?)
:on-click check-for-updates}
(if checking?
(tr "labels.checking-for-updates")
(tr "labels.check-for-updates"))
(when checking?
[:> icon* {:icon-id i/reload
:class (stl/css :checking-icon)
:size "s"}])]])]))
(mf/defc profile-section*
[{:keys [profile team]}]
@ -1512,6 +1546,7 @@
:about-penpot
[:> about-penpot-menu* {:on-close close-sub-menu
:on-close-profile on-close
:on-pointer-enter on-sub-menu-pointer-enter
:on-pointer-leave on-menu-pointer-leave}]
nil))]))

View File

@ -525,6 +525,26 @@
&:hover {
color: var(--menu-foreground-color-hover);
}
&.checking {
color: var(--color-foreground-secondary);
pointer-events: none;
&:hover {
color: var(--color-foreground-secondary);
background-color: transparent;
}
}
}
.checking-icon {
flex-shrink: 0;
animation: spin-animation 1s infinite linear;
}
.submenu-separator {
border-top: $b-1 solid var(--color-background-quaternary);
margin: 0;
}
.about-penpot {

View File

@ -76,6 +76,7 @@
[frontend-tests.tokens.token-errors-test]
[frontend-tests.tokens.workspace-tokens-remap-test]
[frontend-tests.ui.colorpicker-token-set-order-test]
[frontend-tests.ui.check-updates-test]
[frontend-tests.ui.comments-clustering-test]
[frontend-tests.ui.comments-position-modifier-test]
[frontend-tests.ui.ds-controls-numeric-input-test]

View File

@ -0,0 +1,69 @@
;; 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 frontend-tests.ui.check-updates-test
(:require
[app.main.ui.dashboard.check-updates :as dcu]
[cljs.test :as t :include-macros true]))
(def ^:private sample-highlights
(str "# HIGHLIGHTS\n"
"\n"
"## 2.18.0 (Unreleased)\n"
"\n"
"- To do\n"
"\n"
"## 2.17.2\n"
"\n"
"- Background blur is here\n"
"- WebGL rendering gets stronger\n"
"\n"
"## 2.17.1\n"
"\n"
"- MCP connection status and more\n"
"- Design tokens: more visible, more user-friendly\n"))
(t/deftest parse-latest-released-version-skips-unreleased
(t/is (= "2.17.2" (dcu/parse-latest-released-version sample-highlights))))
(t/deftest parse-latest-released-version-first-released
(t/is (= "2.17.2"
(dcu/parse-latest-released-version
"## 2.17.2\n\n- Fix\n\n## 2.17.1\n\n- Fix\n"))))
(t/deftest parse-latest-released-version-only-unreleased
(t/is (nil? (dcu/parse-latest-released-version
"## 2.18.0 (Unreleased)\n\n- WIP\n"))))
(t/deftest parse-latest-released-version-empty
(t/is (nil? (dcu/parse-latest-released-version "")))
(t/is (nil? (dcu/parse-latest-released-version "# HIGHLIGHTS\n"))))
(t/deftest parse-highlights-skips-unreleased-and-collects-bullets
(t/is (= [{:version "2.17.2"
:items ["Background blur is here"
"WebGL rendering gets stronger"]}
{:version "2.17.1"
:items ["MCP connection status and more"
"Design tokens: more visible, more user-friendly"]}]
(dcu/parse-highlights sample-highlights))))
(t/deftest version-compare
(t/is (zero? (dcu/version-compare "2.17.1" "2.17.1")))
(t/is (pos? (dcu/version-compare "2.17.2" "2.17.1")))
(t/is (neg? (dcu/version-compare "2.17.1" "2.17.2")))
(t/is (pos? (dcu/version-compare "3.0.0" "2.99.99")))
(t/is (neg? (dcu/version-compare "2.17.2" "2.17.10"))))
(t/deftest highlights-until-installed
(let [sections (dcu/parse-highlights sample-highlights)]
(t/is (= [{:version "2.17.2"
:items ["Background blur is here"
"WebGL rendering gets stronger"]}]
(dcu/highlights-until-installed sections "2.17.1")))
(t/is (= [] (dcu/highlights-until-installed sections "2.17.2")))
(t/is (= sections (dcu/highlights-until-installed sections "2.16.0")))
(t/is (= [] (dcu/highlights-until-installed sections "2.17.10")))))

View File

@ -3680,6 +3680,51 @@ msgstr "Variant"
msgid "labels.version-notes"
msgstr "Version %s notes"
msgid "labels.check-for-updates"
msgstr "Check for updates"
msgid "labels.checking-for-updates"
msgstr "Checking for updates..."
msgid "dashboard.check-updates.available-title"
msgstr "A NEW PENPOT VERSION IS AVAILABLE"
msgid "dashboard.check-updates.available-message"
msgstr "Newer releases include performance improvements, new capabilities, and fixes that keep your installation current."
msgid "dashboard.check-updates.installed-version"
msgstr "Installed version"
msgid "dashboard.check-updates.latest-version"
msgstr "Latest version"
msgid "dashboard.check-updates.highlights-title"
msgstr "What's new - Highlights"
msgid "dashboard.check-updates.view-changelog"
msgstr "View full changelog"
msgid "dashboard.check-updates.view-release-notes"
msgstr "View release notes"
msgid "dashboard.check-updates.unable-title"
msgstr "UNABLE TO CHECK FOR UPDATES"
msgid "dashboard.check-updates.unable-message"
msgstr "This installation could not reach the Penpot releases service."
msgid "dashboard.check-updates.unable-hint"
msgstr "Check your network or instance configuration and try again."
msgid "dashboard.check-updates.try-again"
msgstr "Try again"
msgid "dashboard.check-updates.uptodate-title"
msgstr "PENPOT IS UP TO DATE"
msgid "dashboard.check-updates.uptodate-message"
msgstr "You're running the latest stable version:"
#: src/app/main/ui/workspace/sidebar/sitemap.cljs:298
msgid "labels.view-only"
msgstr "View only"

View File

@ -3570,6 +3570,51 @@ msgstr "Variante"
msgid "labels.version-notes"
msgstr "Notas versión %s"
msgid "labels.check-for-updates"
msgstr "Comprobar actualizaciones"
msgid "labels.checking-for-updates"
msgstr "Comprobando actualizaciones..."
msgid "dashboard.check-updates.available-title"
msgstr "A NEW PENPOT VERSION IS AVAILABLE"
msgid "dashboard.check-updates.available-message"
msgstr "Las versiones más recientes incluyen mejoras de rendimiento, nuevas capacidades y correcciones que mantienen tu instalación al día."
msgid "dashboard.check-updates.installed-version"
msgstr "Versión instalada"
msgid "dashboard.check-updates.latest-version"
msgstr "Última versión"
msgid "dashboard.check-updates.highlights-title"
msgstr "Novedades - Destacados"
msgid "dashboard.check-updates.view-changelog"
msgstr "Ver changelog completo"
msgid "dashboard.check-updates.view-release-notes"
msgstr "Ver notas de la versión"
msgid "dashboard.check-updates.unable-title"
msgstr "UNABLE TO CHECK FOR UPDATES"
msgid "dashboard.check-updates.unable-message"
msgstr "Esta instalación no pudo contactar con el servicio de versiones de Penpot."
msgid "dashboard.check-updates.unable-hint"
msgstr "Comprueba tu red o la configuración de la instancia e inténtalo de nuevo."
msgid "dashboard.check-updates.try-again"
msgstr "Intentar de nuevo"
msgid "dashboard.check-updates.uptodate-title"
msgstr "PENPOT IS UP TO DATE"
msgid "dashboard.check-updates.uptodate-message"
msgstr "Estás usando la última versión estable:"
#: src/app/main/ui/workspace/sidebar/sitemap.cljs:298
msgid "labels.view-only"
msgstr "Solo lectura"