From 6c2b61e1ad6912f556b0e297c13a3a8a262b182a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 28 Jul 2026 10:59:16 +0200 Subject: [PATCH 1/3] :bug: Fix several issues in RPC command handlers (#10670) * :bug: Fix several issues in RPC command handlers - Reject circular library references in link-file-to-library - Add explicit team permission check in search-files - Constrain search-term max length to 250 chars - Include :deleted-at in file ETag for COND caching - Move storage I/O outside DB transaction in create-file-thumbnail AI-assisted-by: deepseek-v4-pro * :paperclip: Check perms before circular link checks * :bug: Handle circular library reference error Catch :circular-library-reference error from backend when linking files to libraries. Show user-friendly toast notification instead of propagating unhandled error. Add English and Spanish translations. AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/files.clj | 13 +- .../src/app/rpc/commands/files_thumbnails.clj | 116 ++++++++---------- backend/src/app/rpc/commands/search.clj | 8 +- backend/test/backend_tests/rpc_file_test.clj | 72 +++++++++++ .../app/main/data/workspace/libraries.cljs | 7 +- frontend/translations/en.po | 3 + frontend/translations/es.po | 3 + 7 files changed, 154 insertions(+), 68 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 3851ea577b..4daa2dd32a 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -156,11 +156,13 @@ (assoc mfile :permissions perms))) (defn get-file-etag - [{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}] + [{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}] (str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/" (ct/format-inst modified-at :iso) "/" - (uri/map->query-string permissions))) + (uri/map->query-string permissions) + "/" + (some-> deleted-at (ct/format-inst :iso)))) (sv/defmethod ::get-file "Retrieve a file by its ID. Only authenticated users." @@ -1102,6 +1104,13 @@ (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) + + (let [transitive-deps (bfc/get-libraries cfg [library-id])] + (when (contains? transitive-deps file-id) + (ex/raise :type :validation + :code :circular-library-reference + :hint "linking this library would create a circular dependency"))) + (link-file-to-library conn params) (bfc/get-libraries cfg [library-id])) diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 130d9a86be..024bce17e7 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -374,61 +374,6 @@ ;; --- MUTATION COMMAND: create-file-thumbnail -(defn- create-file-thumbnail - [{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id revn props media] :as params}] - (media/validate-media-type! media) - (media/validate-media-size! media) - - (let [file (bfc/get-file cfg file-id - :include-deleted? true - :load-data? false) - - props (db/tjson (or props {})) - path (:path media) - mtype (:mtype media) - hash (sto/calculate-hash path) - data (-> (sto/content path) - (sto/wrap-with-hash hash)) - tnow (ct/now) - - media (sto/put-object! storage - {::sto/content data - ::sto/deduplicate? true - ::sto/touched-at tnow - :content-type mtype - :bucket "file-thumbnail"}) - - thumb (db/get* conn :file-thumbnail - {:file-id file-id - :revn revn} - {::db/remove-deleted false - ::sql/for-update true})] - - (if (some? thumb) - (do - ;; We mark the old media id as touched if it does not match - (when (not= (:id media) (:media-id thumb)) - (sto/touch-object! storage (:media-id thumb))) - - (db/update! conn :file-thumbnail - {:media-id (:id media) - :deleted-at (:deleted-at file) - :updated-at tnow - :props props} - {:file-id file-id - :revn revn})) - - (db/insert! conn :file-thumbnail - {:file-id file-id - :revn revn - :created-at tnow - :updated-at tnow - :deleted-at (:deleted-at file) - :props props - :media-id (:id media)})) - - media)) - (def ^:private schema:create-file-thumbnail [:map {:title "create-file-thumbnail"} @@ -448,12 +393,57 @@ ::rtry/when rtry/conflict-exception? ::sm/params schema:create-file-thumbnail} - ;; FIXME: do not run the thumbnail upload inside a transaction - [cfg {:keys [::rpc/profile-id file-id] :as params}] - (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (files/check-edition-permissions! conn profile-id file-id) - (when-not (db/read-only? conn) - (let [media (create-file-thumbnail cfg params)] - {:uri (files/resolve-public-uri (:id media)) - :id (:id media)}))))) + (media/validate-media-type! (:media params)) + (media/validate-media-size! (:media params)) + + (db/run! cfg files/check-edition-permissions! profile-id file-id) + + (when-not (db/read-only? (::db/pool cfg)) + (let [storage (::sto/storage cfg) + file (bfc/get-file cfg file-id :include-deleted? true :load-data? false) + props (db/tjson (or (:props params) {})) + {:keys [path mtype]} (:media params) + hash (sto/calculate-hash path) + data (-> (sto/content path) + (sto/wrap-with-hash hash)) + tnow (ct/now) + + media (sto/put-object! storage + {::sto/content data + ::sto/deduplicate? true + ::sto/touched-at tnow + :content-type mtype + :bucket "file-thumbnail"}) + + revn (:revn params) + + result (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (let [thumb (db/get* conn :file-thumbnail + {:file-id file-id :revn revn} + {::db/remove-deleted false + ::sql/for-update true})] + (if (some? thumb) + (do + (when (not= (:id media) (:media-id thumb)) + (sto/touch-object! storage (:media-id thumb))) + (db/update! conn :file-thumbnail + {:media-id (:id media) + :deleted-at (:deleted-at file) + :updated-at tnow + :props props} + {:file-id file-id :revn revn})) + (db/insert! conn :file-thumbnail + {:file-id file-id + :revn revn + :created-at tnow + :updated-at tnow + :deleted-at (:deleted-at file) + :props props + :media-id (:id media)})) + media)))] + + (when result + {:uri (files/resolve-public-uri (:id result)) + :id (:id result)})))) diff --git a/backend/src/app/rpc/commands/search.clj b/backend/src/app/rpc/commands/search.clj index 796ddf4810..7b60e6db30 100644 --- a/backend/src/app/rpc/commands/search.clj +++ b/backend/src/app/rpc/commands/search.clj @@ -6,9 +6,11 @@ (ns app.rpc.commands.search (:require + [app.common.data.macros :as dm] [app.common.schema :as sm] [app.db :as db] [app.rpc :as-alias rpc] + [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.util.services :as sv])) @@ -66,11 +68,13 @@ (def ^:private schema:search-files [:map {:title "search-files"} [:team-id ::sm/uuid] - [:search-term {:optional true} :string]]) + [:search-term {:optional true} [:string {:max 250}]]]) (sv/defmethod ::search-files {::doc/added "1.17" ::doc/module :files ::sm/params schema:search-files} [{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}] - (some->> search-term (search-files pool profile-id team-id))) + (dm/with-open [conn (db/open pool)] + (teams/check-read-permissions! conn profile-id team-id) + (some->> search-term (search-files conn profile-id team-id)))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 5460b4143f..1c07f35971 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2319,3 +2319,75 @@ (t/is (not (nil? (:error out)))) (let [edata (-> out :error ex-data)] (t/is (= :not-found (:type edata)))))) + +;; --- Security Fix Tests --- + +(t/deftest link-file-to-library-circular-reference + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + file2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + file3 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + (th/link-file-to-library* {:file-id (:id file3) :library-id (:id file2)}) + (th/link-file-to-library* {:file-id (:id file2) :library-id (:id file1)}) + (let [data {::th/type :link-file-to-library + ::rpc/profile-id (:id profile) + :file-id (:id file1) + :library-id (:id file3)} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (let [edata (-> out :error ex-data)] + (t/is (= :circular-library-reference (:code edata))))))) + +(t/deftest get-file-etag-includes-deleted-at + (let [profile-id (uuid/random) + file1 {:modified-at (ct/now) + :revn 1 + :vern 0 + :deleted-at nil + :permissions {:can-edit true}} + file2 (assoc file1 :deleted-at (ct/now))] + (t/is (not= (files/get-file-etag {::rpc/profile-id profile-id} file1) + (files/get-file-etag {::rpc/profile-id profile-id} file2))))) + +(t/deftest search-files-with-permission + (let [profile (th/create-profile* 1) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + data {::th/type :search-files + ::rpc/profile-id (:id profile) + :team-id (:default-team-id profile) + :search-term "test"} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (vector? (:result out))))) + +(t/deftest search-files-forbidden + (let [profile (th/create-profile* 1) + other (th/create-profile* 2) + data {::th/type :search-files + ::rpc/profile-id (:id other) + :team-id (:default-team-id profile) + :search-term "test"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (let [edata (-> out :error ex-data)] + (t/is (= :not-found (:type edata)))))) + +(t/deftest search-files-term-too-long + (let [profile (th/create-profile* 1) + data {::th/type :search-files + ::rpc/profile-id (:id profile) + :team-id (:default-team-id profile) + :search-term (apply str (repeat 300 "x"))} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (let [edata (-> out :error ex-data)] + (t/is (= :validation (:type edata)))))) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 667f0b2b61..5c09149bad 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1571,7 +1571,12 @@ (as-> libraries-to-load $ (remove loaded-libraries $) (conj $ library-id) - (map #(load-library-file file-id %) $)))))) + (map #(load-library-file file-id %) $)))) + (rx/catch (fn [cause] + (let [error (ex-data cause)] + (if (= (:code error) :circular-library-reference) + (rx/of (ntf/error (tr "errors.circular-library-reference"))) + (rx/throw cause))))))) (rx/of (ptk/reify ::attach-library-finished)) (when (pos? variants-count) (->> (rp/cmd! :get-library-usage {:file-id library-id}) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2155b71895..1f0017d87f 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1490,6 +1490,9 @@ msgstr "The fonts %s could not be loaded" msgid "errors.cannot-upload" msgstr "Cannot upload the media file." +msgid "errors.circular-library-reference" +msgstr "Cannot add library: this would create a circular dependency" + #: src/app/main/ui/comments.cljs:737, src/app/main/ui/comments.cljs:767, src/app/main/ui/comments.cljs:864 msgid "errors.character-limit-exceeded" msgstr "Character limit exceeded" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index ef0ef18989..3c2655035b 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1501,6 +1501,9 @@ msgstr "No se han podido cargar las fuentes %s" msgid "errors.cannot-upload" msgstr "No se puede cargar el archivo multimedia." +msgid "errors.circular-library-reference" +msgstr "No se puede añadir la biblioteca: crearía una dependencia circular" + #: src/app/main/ui/comments.cljs:737, src/app/main/ui/comments.cljs:767, src/app/main/ui/comments.cljs:864 msgid "errors.character-limit-exceeded" msgstr "Se ha superado el límite de caracteres" From 4b994d20aac78723770ae6538bd3372b7936add1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 28 Jul 2026 11:06:00 +0200 Subject: [PATCH 2/3] :bug: Fix leaked focus timers in dashboard sidebar navigation (#10715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix leaked deferred DOM ops on dashboard navigation and template clone The React reconciliation "removeChild" error surfaced during rapid dashboard navigation because several effects scheduled deferred DOM operations (focus, CSS positioning) without returning a cleanup that cancelled them. When the component unmounted before the callback fired, it ran against stale DOM and desynchronized React fiber tree from the actual DOM. - context_menu_a11y.cljs: replace tm/schedule-on-idle (30s idle window) with tm/schedule (setTimeout 0) and return a rx/dispose! cleanup. - dropdown.cljs: capture the tm/schedule handle and dispose it in the effect cleanup. - tooltip.cljs: capture the ts/raf handle and cancel it on cleanup. AI-assisted-by: opencode-go/mimo-v2.5-pro * :bug: Fix leaked focus timers in dashboard sidebar navigation Six sidebar navigation handlers scheduled setTimeout callbacks to mutate tabindex/focus on React-managed title elements without cancelling prior pending callbacks. During rapid keyboard navigation (Projects→Fonts→Libs→Drafts) the stale callbacks fired against unmounted DOM, desyncing React fiber tree and triggering "removeChild" NotFoundError. - sidebar-project*: cancel prior timer in on-key-down - sidebar-search*: cancel prior timer in on-key-press - sidebar-content*: cancel prior timer in go-projects-with-key, go-fonts-with-key, go-drafts-with-key, go-libs-with-key Each handler now stores the timer handle in a component-level ref and disposes any pending handle before scheduling a new one. AI-assisted-by: opencode-go/mimo-v2.5-pro * :recycle: Refactor sidebar focus timer handling into helpers Extract the repeated dispose-before-schedule focus idiom into schedule-focus-by-id! (sidebar.cljs) and focus-and-untabbable! (app.util.dom). Replaces the six duplicated blocks and adds mf/use-effect unmount cleanup to dispose any pending timer in the three sidebar components, closing the remaining leak noted in the original fix. AI-assisted-by: opencode/hy3-free * :recycle: Extract use-focus-timer-ref hook for sidebar components Replace the duplicated mf/use-ref + mf/use-effect cleanup pairs in sidebar-project*, sidebar-search*, and sidebar-content* with a shared use-focus-timer-ref hook (app.main.ui.hooks). The hook creates the ref and disposes any pending timer on unmount via mf/with-effect, reading the ref with mf/ref-val instead of deref. mf/use-effect is now a body-level hook call rather than a let binding. AI-assisted-by: opencode/hy3-free * :paperclip: Add pr feedback fix --- .../main/ui/components/context_menu_a11y.cljs | 8 ++- .../src/app/main/ui/components/dropdown.cljs | 8 ++- .../src/app/main/ui/dashboard/sidebar.cljs | 64 +++++++------------ .../src/app/main/ui/ds/tooltip/tooltip.cljs | 37 +++++------ frontend/src/app/main/ui/hooks.cljs | 10 +++ frontend/src/app/util/dom.cljs | 7 ++ 6 files changed, 70 insertions(+), 64 deletions(-) diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.cljs b/frontend/src/app/main/ui/components/context_menu_a11y.cljs index 75c97554ce..e2e824c19e 100644 --- a/frontend/src/app/main/ui/components/context_menu_a11y.cljs +++ b/frontend/src/app/main/ui/components/context_menu_a11y.cljs @@ -18,6 +18,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [app.util.timers :as tm] + [beicon.v2.core :as rx] [rumext.v2 :as mf])) (def ^:private xf:options @@ -229,8 +230,11 @@ (partial ug/unlisten "penpot:context-menu:open" on-event))) (mf/with-effect [ids] - (tm/schedule-on-idle - #(dom/focus! (dom/get-element (first ids))))) + (let [handle (tm/schedule + (fn [] + (some-> (dom/get-element (first ids)) + (dom/focus!))))] + #(rx/dispose! handle))) (when (some? levels) [:> dropdown-content* props diff --git a/frontend/src/app/main/ui/components/dropdown.cljs b/frontend/src/app/main/ui/components/dropdown.cljs index 894d9ef204..4a0a1b0590 100644 --- a/frontend/src/app/main/ui/components/dropdown.cljs +++ b/frontend/src/app/main/ui/components/dropdown.cljs @@ -11,6 +11,7 @@ [app.util.globals :as globals] [app.util.keyboard :as kbd] [app.util.timers :as tm] + [beicon.v2.core :as rx] [goog.events :as events] [rumext.v2 :as mf]) (:import goog.events.EventType)) @@ -45,9 +46,10 @@ (fn [] (let [keys [(events/listen globals/document EventType.CLICK on-click) (events/listen globals/document EventType.CONTEXTMENU on-click) - (events/listen globals/document EventType.KEYUP on-keyup)]] - (tm/schedule #(mf/set-ref-val! listening-ref true)) - #(run! events/unlistenByKey keys)))] + (events/listen globals/document EventType.KEYUP on-keyup)] + timer (tm/schedule #(mf/set-ref-val! listening-ref true))] + #(do (rx/dispose! timer) + (run! events/unlistenByKey keys))))] (mf/use-effect on-mount) children)) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index eeda604490..53d87a8878 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -40,6 +40,7 @@ [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.main.ui.hooks :refer [use-focus-timer-ref]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.nitrate.nitrate-form] [app.util.dom :as dom] @@ -94,6 +95,14 @@ (def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon") (def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle") +(defn schedule-focus-by-id! + [ref element-id] + (when-let [h (mf/ref-val ref)] + (ts/dispose! h)) + (mf/set-ref-val! ref + (ts/schedule + #(dom/focus-and-untabbable! (dom/get-element element-id))))) + (mf/defc sidebar-project* {::mf/private true} [{:keys [item is-selected]}] @@ -112,6 +121,8 @@ project-id (get item :id) + focus-timer-ref (use-focus-timer-ref) + on-click (mf/use-fn (mf/deps project-id) @@ -123,14 +134,9 @@ (mf/deps project-id) (fn [event] (when (kbd/enter? event) - (st/emit! - (dcm/go-to-dashboard-files :project-id project-id)) - (ts/schedule - (fn [] - (when-let [title (dom/get-element (str project-id))] - (dom/set-attribute! title "tabindex" "0") - (dom/focus! title) - (dom/set-attribute! title "tabindex" "-1"))))))) + (schedule-focus-by-id! focus-timer-ref (str project-id)) + (st/emit! (dcm/go-to-dashboard-files :project-id project-id))))) + on-menu-click (mf/use-fn @@ -228,6 +234,8 @@ focused? (mf/use-state false) emit! (mf/use-memo #(f/debounce st/emit! 500)) + focus-timer-ref (use-focus-timer-ref) + on-search-blur (mf/use-fn (fn [_] @@ -254,13 +262,7 @@ (mf/use-fn (fn [e] (when (kbd/enter? e) - (ts/schedule - (fn [] - (let [search-title (dom/get-element (str "dashboard-search-title"))] - (when search-title - (dom/set-attribute! search-title "tabindex" "0") - (dom/focus! search-title) - (dom/set-attribute! search-title "tabindex" "-1"))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-search-title") (dom/prevent-default e) (dom/stop-propagation e)))) @@ -948,6 +950,8 @@ nitrate? (contains? cf/flags :nitrate) + focus-timer-ref (use-focus-timer-ref) + go-projects (mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent))) @@ -957,12 +961,7 @@ (fn [] (st/emit! (dcm/go-to-dashboard-recent :team-id team-id)) - (ts/schedule - (fn [] - (when-let [projects-title (dom/get-element "dashboard-projects-title")] - (dom/set-attribute! projects-title "tabindex" "0") - (dom/focus! projects-title) - (dom/set-attribute! projects-title "tabindex" "-1")))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-projects-title"))) go-fonts (mf/use-fn @@ -975,13 +974,7 @@ (fn [] (st/emit! (dcm/go-to-dashboard-fonts :team-id team-id)) - (ts/schedule - (fn [] - (let [font-title (dom/get-element "dashboard-fonts-title")] - (when font-title - (dom/set-attribute! font-title "tabindex" "0") - (dom/focus! font-title) - (dom/set-attribute! font-title "tabindex" "-1"))))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-fonts-title"))) go-drafts (mf/use-fn @@ -994,12 +987,7 @@ (mf/deps team-id default-project-id) (fn [] (st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id)) - (ts/schedule - (fn [] - (when-let [title (dom/get-element "dashboard-drafts-title")] - (dom/set-attribute! title "tabindex" "0") - (dom/focus! title) - (dom/set-attribute! title "tabindex" "-1")))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-drafts-title"))) go-libs (mf/use-fn @@ -1012,13 +1000,7 @@ (fn [] (st/emit! (dcm/go-to-dashboard-libraries :team-id team-id)) - (ts/schedule - (fn [] - (let [libs-title (dom/get-element "dashboard-libraries-title")] - (when libs-title - (dom/set-attribute! libs-title "tabindex" "0") - (dom/focus! libs-title) - (dom/set-attribute! libs-title "tabindex" "-1"))))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-libraries-title"))) pinned-projects (mf/with-memo [projects] diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index bc20c517c9..e3d6b153fb 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -322,25 +322,26 @@ (let [trigger-el (mf/ref-val trigger-ref) tooltip-el (mf/ref-val tooltip-ref)] (when (and trigger-el tooltip-el) - (ts/raf - (fn [] - (let [origin-brect (dom/get-bounding-rect trigger-el) - tooltip-brect (dom/get-bounding-rect tooltip-el) - window-size (dom/get-window-size)] - (when-let [[new-placement placement-rect] - (find-matching-placement - placement - tooltip-brect - origin-brect - window-size - offset)] - (dom/set-css-property! tooltip-el "inset-block-start" - (str (:top placement-rect) "px")) - (dom/set-css-property! tooltip-el "inset-inline-start" - (str (:left placement-rect) "px")) + (let [raf-id (ts/raf + (fn [] + (let [origin-brect (dom/get-bounding-rect trigger-el) + tooltip-brect (dom/get-bounding-rect tooltip-el) + window-size (dom/get-window-size)] + (when-let [[new-placement placement-rect] + (find-matching-placement + placement + tooltip-brect + origin-brect + window-size + offset)] + (dom/set-css-property! tooltip-el "inset-block-start" + (str (:top placement-rect) "px")) + (dom/set-css-property! tooltip-el "inset-inline-start" + (str (:left placement-rect) "px")) - (when (not= new-placement placement) - (reset! placement* new-placement))))))))))) + (when (not= new-placement placement) + (reset! placement* new-placement))))))] + #(ts/cancel-af! raf-id))))))) [:> :div props children diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index ae8ebd30d5..bb541dd8ea 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -281,6 +281,16 @@ (mf/set-ref-val! ref val)) (mf/ref-val ref))) +;; FIXME: replace with rumext +(defn use-focus-timer-ref + "Returns a ref for scheduling focus timers and disposes any pending + timer on component unmount." + [] + (let [ref (mf/use-ref nil)] + (mf/with-effect [] + #(some-> (mf/ref-val ref) ts/dispose!)) + ref)) + ;; FIXME: rename to use-focus-objects (defn with-focus-objects ([objects] diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index f4be22b6c9..d885e7771b 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -699,6 +699,13 @@ (when (some? node) (.setAttribute node attr value))) +(defn focus-and-untabbable! + [^js node] + (when (some? node) + (set-attribute! node "tabindex" "0") + (focus! node) + (set-attribute! node "tabindex" "-1"))) + (defn set-style! [^js node ^string style value] (when (some? node) From bbc7e9bee932c588e617cb31fe4b417379bcaea9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 28 Jul 2026 12:47:33 +0200 Subject: [PATCH 3/3] :sparkles: Add a script for run ci-like tasks --- .gitignore | 1 + AGENTS.md | 1 + scripts/ci | 478 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100755 scripts/ci diff --git a/.gitignore b/.gitignore index a0fb7f7f5a..76da22b35f 100644 --- a/.gitignore +++ b/.gitignore @@ -101,5 +101,6 @@ opencode.json /.opencode/plans /.opencode/reports /.opencode/prompts +/.ci-logs /.codex/ /tools/__pycache__ diff --git a/AGENTS.md b/AGENTS.md index b6aae1f32d..05284c1c73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,4 +109,5 @@ precision while maintaining a strong focus on maintainability and performance. - `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend). - `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines. - `scripts/check-fmt-clj` — Check Clojure formatting without modifying files. +- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`. diff --git a/scripts/ci b/scripts/ci new file mode 100755 index 0000000000..df693a9cd0 --- /dev/null +++ b/scripts/ci @@ -0,0 +1,478 @@ +#!/usr/bin/env bash +# scripts/ci - CI orchestration script for Penpot monorepo + +set -euo pipefail + +# Constants +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +LOG_DIR="$PROJECT_ROOT/.ci-logs" + +# Color codes +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# Available modules +ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library") + +# Module commands +declare -A LINT_CMD=( + [frontend]="pnpm run lint:clj && pnpm run lint:js && pnpm run lint:scss" + [backend]="pnpm run lint" + [common]="pnpm run lint:clj" + [render-wasm]="./lint" + [exporter]="pnpm run lint" + [mcp]="" + [plugins]="pnpm run lint" + [library]="pnpm run lint" +) + +declare -A TEST_CMD=( + [frontend]="pnpm run test:quiet" + [backend]="clojure -M:dev:test" + [common]="clojure -M:dev:test && pnpm run test:quiet" + [render-wasm]="./test" + [exporter]="" + [mcp]="pnpm run test" + [plugins]="pnpm run test" + [library]="pnpm run test" +) + +declare -A FMT_CHECK_CMD=( + [frontend]="pnpm run check-fmt:clj && pnpm run check-fmt:js && pnpm run check-fmt:scss" + [backend]="pnpm run check-fmt" + [common]="pnpm run check-fmt:clj && pnpm run check-fmt:js" + [render-wasm]="cargo fmt --check" + [exporter]="pnpm run check-fmt" + [mcp]="pnpm run fmt:check" + [plugins]="pnpm run format:check" + [library]="pnpm run check-fmt" +) + +declare -A FMT_FIX_CMD=( + [frontend]="pnpm run fmt" + [backend]="pnpm run fmt" + [common]="pnpm run fmt:clj && pnpm run fmt:js" + [render-wasm]="cargo fmt" + [exporter]="pnpm run fmt" + [mcp]="pnpm run fmt" + [plugins]="pnpm run format" + [library]="pnpm run fmt" +) + +# Default options +MODULES=() +TASKS=("lint" "test" "fmt") +FIX_MODE=false +FAIL_FAST=false +VERBOSE=true +DRY_RUN=false + +# Results tracking +declare -A RESULTS=() +declare -a FAILED_LOGS=() + +timestamp() { + date "+%H:%M:%S" +} + +log_info() { + echo -e "${BLUE}[$(timestamp)]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[$(timestamp)] ✓${NC} $1" +} + +log_error() { + echo -e "${RED}[$(timestamp)] ✗${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[$(timestamp)] ⚠${NC} $1" +} + +log_header() { + local module=$1 + local current=$2 + local total=$3 + echo "" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${CYAN} ${BOLD}[$current/$total] $module${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +} + +run_task() { + local module=$1 + local task=$2 + local cmd=$3 + local logfile="$LOG_DIR/${module}-${task}.log" + + if [[ -z "$cmd" ]]; then + log_warning "$task: not defined for $module, skipping" + RESULTS["$module:$task"]="SKIPPED" + return 0 + fi + + if [[ "$DRY_RUN" == "true" ]]; then + log_info "$task: would run '$cmd' in $module/" + RESULTS["$module:$task"]="DRY-RUN" + return 0 + fi + + log_info "Running ${BOLD}$task${NC} for $module..." + + mkdir -p "$LOG_DIR" + local start_time + start_time=$(date +%s) + + if [[ "$VERBOSE" == "true" ]]; then + echo -e "${BLUE} cmd: $cmd${NC}" + fi + + local exit_code=0 + (cd "$PROJECT_ROOT/$module" && eval "$cmd") > "$logfile" 2>&1 || exit_code=$? + + local end_time + end_time=$(date +%s) + local duration=$((end_time - start_time)) + + if [[ $exit_code -eq 0 ]]; then + log_success "$task passed for $module ${BLUE}(${duration}s)${NC}" + RESULTS["$module:$task"]="PASSED" + return 0 + else + log_error "$task failed for $module ${BLUE}(${duration}s)${NC}" + echo -e " ${RED}log: $logfile${NC}" + RESULTS["$module:$task"]="FAILED" + FAILED_LOGS+=("$logfile") + if [[ "$VERBOSE" == "true" ]]; then + echo -e "${YELLOW} --- last 30 lines ---${NC}" + tail -n 30 "$logfile" | sed 's/^/ /' + echo -e "${YELLOW} ---------------------${NC}" + fi + return 1 + fi +} + +run_module() { + local module=$1 + local failed=false + + for task in "${TASKS[@]}"; do + local cmd="" + case $task in + lint) + cmd="${LINT_CMD[$module]:-}" + ;; + test) + cmd="${TEST_CMD[$module]:-}" + ;; + fmt) + if [[ "$FIX_MODE" == "true" ]]; then + cmd="${FMT_FIX_CMD[$module]:-}" + else + cmd="${FMT_CHECK_CMD[$module]:-}" + fi + ;; + esac + + if ! run_task "$module" "$task" "$cmd"; then + failed=true + if [[ "$FAIL_FAST" == "true" ]]; then + return 1 + fi + fi + done + + if [[ "$failed" == "true" ]]; then + return 1 + fi + return 0 +} + +print_summary() { + echo "" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${CYAN} ${BOLD}SUMMARY${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + local total=0 + local passed=0 + local failed=0 + local skipped=0 + + for module in "${MODULES[@]}"; do + for task in "${TASKS[@]}"; do + local result="${RESULTS[$module:$task]:-N/A}" + total=$((total + 1)) + case $result in + PASSED) + passed=$((passed + 1)) + echo -e " ${GREEN}✓${NC} $module:$task" + ;; + FAILED) + failed=$((failed + 1)) + echo -e " ${RED}✗${NC} $module:$task" + ;; + SKIPPED) + skipped=$((skipped + 1)) + echo -e " ${YELLOW}○${NC} $module:$task" + ;; + DRY-RUN) + echo -e " ${BLUE}~${NC} $module:$task" + ;; + esac + done + done + + echo "" + echo -e " ${BOLD}Total:${NC} $total ${GREEN}Passed:${NC} $passed ${RED}Failed:${NC} $failed ${YELLOW}Skipped:${NC} $skipped" + + if [[ $failed -gt 0 ]]; then + echo "" + echo -e " ${RED}${BOLD}Failed logs:${NC}" + for logfile in "${FAILED_LOGS[@]}"; do + echo -e " ${RED}•${NC} $logfile" + done + return 1 + fi + return 0 +} + +validate_module() { + local mod=$1 + for valid in "${ALL_MODULES[@]}"; do + if [[ "$valid" == "$mod" ]]; then + return 0 + fi + done + return 1 +} + +usage() { + cat <&2 + echo "Run '$(basename "$0") --help' for usage." >&2 + exit 1 + ;; + *) + if validate_module "$1"; then + MODULES+=("$1") + else + echo -e "${RED}Error: Unknown module '$1'${NC}" >&2 + echo "Valid modules: ${ALL_MODULES[*]}" >&2 + exit 1 + fi + shift + ;; + esac + done + + # Clean empty entries from TASKS after removal + TASKS=("${TASKS[@]// /}") + TASKS=("${TASKS[@]/#/}") + local clean_tasks=() + for t in "${TASKS[@]}"; do + if [[ -n "$t" ]]; then + clean_tasks+=("$t") + fi + done + TASKS=("${clean_tasks[@]}") + + # Set modules + if [[ "$run_all" == "true" ]]; then + MODULES=("${ALL_MODULES[@]}") + fi + + # Apply exclusions + if [[ ${#exclude_modules[@]} -gt 0 ]]; then + local filtered=() + for mod in "${MODULES[@]}"; do + local excluded=false + for ex in "${exclude_modules[@]}"; do + if [[ "$mod" == "$ex" ]]; then + excluded=true + break + fi + done + if [[ "$excluded" == "false" ]]; then + filtered+=("$mod") + fi + done + MODULES=("${filtered[@]}") + fi + + # Validate + if [[ ${#MODULES[@]} -eq 0 ]]; then + echo -e "${RED}Error: No modules specified.${NC}" >&2 + echo "Use --all or specify modules: ${ALL_MODULES[*]}" >&2 + exit 1 + fi + + if [[ ${#TASKS[@]} -eq 0 ]]; then + echo -e "${RED}Error: No tasks selected.${NC}" >&2 + exit 1 + fi + + # Print configuration + echo "" + echo -e "${CYAN}${BOLD}Penpot CI Orchestrator${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD}Modules:${NC} ${MODULES[*]}" + echo -e " ${BOLD}Tasks:${NC} ${TASKS[*]}" + echo -e " ${BOLD}Fix mode:${NC} $FIX_MODE" + echo -e " ${BOLD}Fail-fast:${NC} $FAIL_FAST" + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${BOLD}Mode:${NC} ${YELLOW}DRY RUN${NC}" + fi + echo "" + + # Run + local total_modules=${#MODULES[@]} + local current=0 + local modules_failed=0 + local start_time + start_time=$(date +%s) + + for module in "${MODULES[@]}"; do + current=$((current + 1)) + log_header "$module" "$current" "$total_modules" + + if ! run_module "$module"; then + modules_failed=$((modules_failed + 1)) + if [[ "$FAIL_FAST" == "true" ]]; then + log_error "Fail-fast: stopping due to failure in $module" + break + fi + fi + done + + local end_time + end_time=$(date +%s) + local total_duration=$((end_time - start_time)) + + print_summary || true + + echo "" + echo -e " ${BOLD}Duration:${NC} ${total_duration}s" + echo "" + + if [[ $modules_failed -gt 0 ]]; then + exit 1 + fi + exit 0 +} + +main "$@"