Merge remote-tracking branch 'origin/staging'

This commit is contained in:
Andrey Antukh 2026-08-03 09:15:03 +02:00
commit d835baefec
55 changed files with 1982 additions and 538 deletions

1
.gitignore vendored
View File

@ -101,5 +101,6 @@ opencode.json
/.opencode/plans /.opencode/plans
/.opencode/reports /.opencode/reports
/.opencode/prompts /.opencode/prompts
/.ci-logs
/.codex/ /.codex/
/tools/__pycache__ /tools/__pycache__

View File

@ -137,17 +137,32 @@ E2E tests should not be added unless explicitly requested.
## Execution discipline ## Execution discipline
When running CLJS/JS tests (frontend, common): **CRITICAL: Test output handling rules**
When running ANY test command (CLJS/JS or JVM):
1. **NEVER pipe test output directly to `| head`, `| tail`, `| grep`, or similar filters** — this can hide failures and cause you to miss critical errors.
2. **ALWAYS pipe to a file first, then read the file:**
```bash
# CORRECT:
pnpm run test 2>&1 > /tmp/test-output.txt
grep -A 5 "failures" /tmp/test-output.txt
# WRONG:
pnpm run test 2>&1 | tail -20
pnpm run test 2>&1 | grep "failures"
```
3. **Use `--focus` to narrow test scope** instead of filtering output.
4. **Read the full output file** to understand test results completely.
When running CLJS/JS tests (frontend, common):
- **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output. - **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output.
- **Never pipe test output through `tail`, `head`, or similar filters** — doing so can silently hide test failures. Use `--focus` to narrow scope instead.
- **If you need to filter output, tee to a temp file first:** `pnpm run test:quiet 2>&1 | tee /tmp/penpot-test-output.txt`. The full output is preserved on disk so you can `grep`/`tail`/`head` the file without re-running.
- Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs). - Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs).
- After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`. - After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
When running JVM tests (backend, common): When running JVM tests (backend, common):
- Use `clojure -M:dev:test` directly (no pnpm wrapper). - Use `clojure -M:dev:test` directly (no pnpm wrapper).
- The same no-piping rule applies: use `--focus` to narrow scope. - Same file-piping rule applies.
## Verification Checklist ## Verification Checklist

View File

@ -30,7 +30,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
Include concise sections covering: Include concise sections covering:
- what changed and why; - what changed and why;
- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`); - related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- screenshots or recordings for UI-visible changes; - screenshots or recordings for UI-visible changes;
- testing performed and residual risk; - testing performed and residual risk;
- breaking changes or migration notes, if any. - breaking changes or migration notes, if any.

View File

@ -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/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines. - `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files. - `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`.

View File

@ -65,12 +65,25 @@
:else :else
request))) request)))
;; The specific-exception branches below (IAE,
;; RequestTooBigException, EOFException) raise with
;; `ex/raise` rather than calling `errors/handle` directly.
;; This is intentional: the throw is caught by the
;; top-level error handler in `app.http/router-handler`
;; (`backend/src/app/http.clj`), which routes every
;; uncaught exception through `errors/handle`. The
;; per-route `wrap-errors` middleware in the route list
;; is a defensive layer; correctness does not depend on
;; it. Raising here keeps the cond uniform with the
;; existing RequestTooBigException / EOFException
;; branches.
(handle-error [cause request] (handle-error [cause request]
(cond (cond
(instance? RuntimeException cause) (instance? IllegalArgumentException cause)
(if-let [cause (ex-cause cause)] (ex/raise :type :validation
(handle-error cause request) :code :malformed-json
(errors/handle cause request)) :hint (ex-message cause)
:cause cause)
(instance? RequestTooBigException cause) (instance? RequestTooBigException cause)
(ex/raise :type :validation (ex/raise :type :validation
@ -83,6 +96,11 @@
:hint (ex-message cause) :hint (ex-message cause)
:cause cause) :cause cause)
(instance? RuntimeException cause)
(if-let [cause (ex-cause cause)]
(handle-error cause request)
(errors/handle cause request))
:else :else
(errors/handle cause request)))] (errors/handle cause request)))]

View File

@ -156,11 +156,13 @@
(assoc mfile :permissions perms))) (assoc mfile :permissions perms)))
(defn get-file-etag (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) "/" (str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
(ct/format-inst modified-at :iso) (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 (sv/defmethod ::get-file
"Retrieve a file by its ID. Only authenticated users." "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 file-id)
(check-edition-permissions! conn profile-id library-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) (link-file-to-library conn params)
(bfc/get-libraries cfg [library-id])) (bfc/get-libraries cfg [library-id]))

View File

@ -374,61 +374,6 @@
;; --- MUTATION COMMAND: create-file-thumbnail ;; --- 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 (def ^:private
schema:create-file-thumbnail schema:create-file-thumbnail
[:map {:title "create-file-thumbnail"} [:map {:title "create-file-thumbnail"}
@ -448,12 +393,57 @@
::rtry/when rtry/conflict-exception? ::rtry/when rtry/conflict-exception?
::sm/params schema:create-file-thumbnail} ::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}] [cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (media/validate-media-type! (:media params))
(files/check-edition-permissions! conn profile-id file-id) (media/validate-media-size! (:media params))
(when-not (db/read-only? conn)
(let [media (create-file-thumbnail cfg params)] (db/run! cfg files/check-edition-permissions! profile-id file-id)
{:uri (files/resolve-public-uri (:id media))
:id (:id media)}))))) (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)}))))

View File

@ -6,9 +6,11 @@
(ns app.rpc.commands.search (ns app.rpc.commands.search
(:require (:require
[app.common.data.macros :as dm]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.db :as db] [app.db :as db]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.util.services :as sv])) [app.util.services :as sv]))
@ -66,11 +68,13 @@
(def ^:private schema:search-files (def ^:private schema:search-files
[:map {:title "search-files"} [:map {:title "search-files"}
[:team-id ::sm/uuid] [:team-id ::sm/uuid]
[:search-term {:optional true} :string]]) [:search-term {:optional true} [:string {:max 250}]]])
(sv/defmethod ::search-files (sv/defmethod ::search-files
{::doc/added "1.17" {::doc/added "1.17"
::doc/module :files ::doc/module :files
::sm/params schema:search-files} ::sm/params schema:search-files}
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}] [{: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))))

View File

@ -21,19 +21,73 @@
[clojure.test :as t] [clojure.test :as t]
[mockery.core :refer [with-mocks]] [mockery.core :refer [with-mocks]]
[yetti.request :as yreq] [yetti.request :as yreq]
[yetti.response :as yres])) [yetti.response :as yres])
(:import
io.undertow.server.RequestTooBigException))
(t/use-fixtures :once th/state-init) (t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
(defrecord DummyRequest [headers cookies] (defrecord DummyRequest [headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert]
yreq/IRequestCookies yreq/IRequestCookies
(get-cookie [_ name] (get-cookie [_ name]
{:value (get cookies name)}) {:value (get cookies name)})
yreq/IRequest yreq/IRequest
(get-header [_ name] (get-header [_ name]
(get headers name))) (get headers name))
(method [_] method)
(body [_] body-stream)
(path [_] path)
(query [_] query)
(server-port [_] server-port)
(server-name [_] server-name)
(remote-addr [_] remote-addr)
(ssl-client-cert [_] ssl-client-cert)
(scheme [_] scheme)
(protocol [_] protocol))
(defn- make-dummy-request
"Constructs a DummyRequest from an options map. Every key is
optional; missing values fall back to sensible defaults. New
fields added to DummyRequest won't break existing call sites
as long as this constructor keeps its `:or` defaults in sync.
Recognized keys:
:headers map of header name value
:cookies map of cookie name value
:method HTTP method keyword (default :get)
:body-stream InputStream for the body (used directly)
:body-bytes bytes or string for the body; wrapped in a
ByteArrayInputStream if :body-stream is not
given
:remote-addr string (default \"127.0.0.1\")
:server-name string (default \"test\")
:server-port long (default 0)
:scheme keyword (default :http)
:protocol string (default \"HTTP/1.1\")
:path string (default \"/test\")
:query string or nil (default nil)
:ssl-client-cert X509Certificate or nil (default nil)"
[{:keys [headers cookies method body-stream body-bytes
remote-addr server-name server-port scheme protocol
path query ssl-client-cert]
:or {headers {} cookies {} method :get
body-stream nil
remote-addr "127.0.0.1" server-name "test" server-port 0
scheme :http protocol "HTTP/1.1" path "/test" query nil
ssl-client-cert nil}}]
(let [body-stream (or body-stream
(when body-bytes
(java.io.ByteArrayInputStream.
(if (string? body-bytes)
(.getBytes ^String body-bytes "UTF-8")
body-bytes))))]
(->DummyRequest headers cookies method body-stream
remote-addr server-name server-port
scheme protocol path query ssl-client-cert)))
(t/deftest auth-middleware-1 (t/deftest auth-middleware-1
(let [request (volatile! nil) (let [request (volatile! nil)
@ -41,11 +95,11 @@
(fn [req] (vreset! request req)) (fn [req] (vreset! request req))
{})] {})]
(handler (->DummyRequest {} {})) (handler (make-dummy-request {}))
(t/is (nil? (::http/auth-data @request))) (t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {"authorization" "Token aaaa"} {})) (handler (make-dummy-request {:headers {"authorization" "Token aaaa"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)] (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :token token-type)) (t/is (= :token token-type))
@ -58,10 +112,10 @@
(fn [req] (vreset! request req)) (fn [req] (vreset! request req))
{})] {})]
(handler (->DummyRequest {} {})) (handler (make-dummy-request {}))
(t/is (nil? (::http/auth-data @request))) (t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {})) (handler (make-dummy-request {:headers {"authorization" "Bearer aaaa"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)] (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :bearer token-type)) (t/is (= :bearer token-type))
@ -74,10 +128,10 @@
(fn [req] (vreset! request req)) (fn [req] (vreset! request req))
{})] {})]
(handler (->DummyRequest {} {})) (handler (make-dummy-request {}))
(t/is (nil? (::http/auth-data @request))) (t/is (nil? (::http/auth-data @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"})) (handler (make-dummy-request {:cookies {"auth-token" "foobar"}}))
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)] (let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
(t/is (= :cookie token-type)) (t/is (= :cookie token-type))
@ -89,16 +143,16 @@
(fn [req] {::yres/status 200}) (fn [req] {::yres/status 200})
{:test1 "secret-key"})] {:test1 "secret-key"})]
(let [response (handler (->DummyRequest {} {}))] (let [response (handler (make-dummy-request {}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key2"} {}))] (let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key2"}}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))] (let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key"}}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "test1 secret-key"} {}))] (let [response (handler (make-dummy-request {:headers {"x-shared-key" "test1 secret-key"}}))]
(t/is (= 200 (::yres/status response)))))) (t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz (t/deftest access-token-authz
@ -209,7 +263,7 @@
:user-agent "user agent"}) :user-agent "user agent"})
(#'session/assign-token cfg)) (#'session/assign-token cfg))
response (handler (->DummyRequest {} {"auth-token" (:token session)})) response (handler (make-dummy-request {:cookies {"auth-token" (:token session)}}))
{:keys [token claims] token-type :type} {:keys [token claims] token-type :type}
(get response ::http/auth-data)] (get response ::http/auth-data)]
@ -220,3 +274,127 @@
(t/is (= "penpot" (:aud claims))) (t/is (= "penpot" (:aud claims)))
(t/is (= (:id session) (:sid claims))) (t/is (= (:id session) (:sid claims)))
(t/is (= (:id profile) (:uid claims))))) (t/is (= (:id profile) (:uid claims)))))
(t/deftest parse-request-illegal-argument-exception
;; clojure.data.json raises IllegalArgumentException (case
;; fall-through) on several kinds of malformed input. The
;; parse-request middleware should convert any such IAE into a
;; 400 :malformed-json validation error rather than letting it
;; surface as a 500 internal error. Because the conversion is
;; done by raising an ex-info (caught by the top-level error
;; handler in app.http/router-handler), this test asserts on
;; the ex-info thrown by wrap-parse-request directly.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] {::yres/status 200 ::yres/body :ok}))
;; Body contains the bytes for: {"x": "\}"} -- a string
;; value with a backslash followed by '}', which
;; clojure.data.json v0.5.x cannot handle.
body (.getBytes "{\"x\": \"\\}\"}" "UTF-8")
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes body})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :malformed-json (-> ex ex-data :code)))
(t/is (string? (-> ex ex-data :hint)))))
(t/deftest parse-request-request-too-big-exception
;; When RequestTooBigException is raised (e.g. the request body
;; exceeded the configured size limit), the middleware should
;; convert it to a 413 :request-body-too-large validation
;; error.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (RequestTooBigException. "too large"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :request-body-too-large (-> ex ex-data :code)))
(t/is (string? (-> ex ex-data :hint)))))
(t/deftest parse-request-eof-exception
;; When java.io.EOFException is raised (e.g. the body stream
;; was closed before the parser could read it), the middleware
;; should convert it to a 400 :malformed-json validation error.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (java.io.EOFException. "stream closed"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :malformed-json (-> ex ex-data :code)))
(t/is (string? (-> ex ex-data :hint)))))
(t/deftest parse-request-runtime-exception-with-cause
;; When a RuntimeException with a non-nil ex-cause is raised,
;; the middleware should recurse on the cause and dispatch
;; through the specific-exception branches. Here we wrap an
;; IllegalArgumentException in a RuntimeException and verify
;; it surfaces as :malformed-json.
(let [iae (IllegalArgumentException. "No matching clause: 99")
wrapped (doto (RuntimeException. "wrapped")
(.initCause iae))
handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw wrapped)))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
ex (try
(handler request)
(catch clojure.lang.ExceptionInfo e e))]
(t/is (instance? clojure.lang.ExceptionInfo ex))
(t/is (= :validation (-> ex ex-data :type)))
(t/is (= :malformed-json (-> ex ex-data :code)))))
(t/deftest parse-request-runtime-exception-without-cause
;; When a bare RuntimeException (no ex-cause) is raised, the
;; middleware should fall through to errors/handle's :default
;; path and return a 500 with :type :server-error :code
;; :unexpected. This is the "true internal error" path.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (RuntimeException. "boom"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
response (handler request)
body (::yres/body response)]
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :unexpected (:code body)))
(t/is (= "boom" (:hint body)))))
(t/deftest parse-request-non-runtime-throwable
;; When a non-RuntimeException Throwable is raised (e.g. an
;; Error subclass or a non-RuntimeException checked-style
;; exception), the middleware should fall through to the
;; :else branch and call errors/handle. java.io.IOException
;; has a dedicated handle-exception method that returns 500
;; with :code :io-exception.
(let [handler (#'app.http.middleware/wrap-parse-request
(fn [_] (throw (java.io.IOException. "network gone"))))
request (make-dummy-request
{:method :post
:headers {"content-type" "application/json"}
:body-bytes (.getBytes "{}" "UTF-8")})
response (handler request)
body (::yres/body response)]
(t/is (= 500 (::yres/status response)))
(t/is (= :server-error (:type body)))
(t/is (= :io-exception (:code body)))
(t/is (= "network gone" (:hint body)))))

View File

@ -2319,3 +2319,75 @@
(t/is (not (nil? (:error out)))) (t/is (not (nil? (:error out))))
(let [edata (-> out :error ex-data)] (let [edata (-> out :error ex-data)]
(t/is (= :not-found (:type edata)))))) (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))))))

View File

@ -121,74 +121,79 @@
(defn layout-content-points (defn layout-content-points
[bounds parent children objects] [bounds parent children objects]
(let [parent-id (dm/get-prop parent :id) (let [parent-id (dm/get-prop parent :id)
parent-bounds @(get bounds parent-id) parent-bounds (get bounds parent-id)]
reverse? (ctl/reverse? parent) (when-let [parent-bounds (some-> parent-bounds deref)]
children (cond->> children (not reverse?) reverse)] (let [reverse? (ctl/reverse? parent)
children (cond->> children (not reverse?) reverse)]
(loop [children (seq children) (loop [children (seq children)
result (transient []) result (transient [])
correct-v (gpt/point 0)] correct-v (gpt/point 0)]
(if (not children) (if (not children)
(persistent! result) (persistent! result)
(let [child (first children) (let [child (first children)
child-id (dm/get-prop child :id) child-id (dm/get-prop child :id)
child-bounds @(get bounds child-id) child-bounds-ref (get bounds child-id)
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child) child-bounds (some-> child-bounds-ref deref)
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child)
[child-bounds correct-v] [child-bounds correct-v]
(if (or (ctl/fill-width? child) (ctl/fill-height? child)) (if (and child-bounds
(child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects) (or (ctl/fill-width? child) (ctl/fill-height? child)))
[(->> child-bounds (map #(gpt/add % correct-v))) correct-v]) (child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects)
[(when child-bounds
(->> child-bounds (map #(gpt/add % correct-v))))
correct-v])
child-bounds child-bounds
(when (d/not-empty? child-bounds) (when (d/not-empty? child-bounds)
(-> (gpo/parent-coords-bounds child-bounds parent-bounds) (-> (gpo/parent-coords-bounds child-bounds parent-bounds)
(gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))] (gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))]
(recur (next children) (recur (next children)
(cond-> result (some? child-bounds) (conj! child-bounds)) (cond-> result (some? child-bounds) (conj! child-bounds))
correct-v)))))) correct-v))))))))
(defn layout-content-bounds (defn layout-content-bounds
[bounds {:keys [layout-padding] :as parent} children objects] [bounds {:keys [layout-padding] :as parent} children objects]
(let [parent-id (:id parent) (let [parent-id (:id parent)
parent-bounds @(get bounds parent-id) parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [row? (ctl/row? parent)
col? (ctl/col? parent)
space-around? (ctl/space-around? parent)
space-evenly? (ctl/space-evenly? parent)
content-evenly? (ctl/content-evenly? parent)
[layout-gap-row layout-gap-col] (ctl/gaps parent)
row? (ctl/row? parent) row-pad (if (or (and col? space-evenly?)
col? (ctl/col? parent) (and col? space-around?)
space-around? (ctl/space-around? parent) (and row? content-evenly?))
space-evenly? (ctl/space-evenly? parent) layout-gap-row
content-evenly? (ctl/content-evenly? parent) 0)
[layout-gap-row layout-gap-col] (ctl/gaps parent)
row-pad (if (or (and col? space-evenly?) col-pad (if (or (and row? space-evenly?)
(and col? space-around?) (and row? space-around?)
(and row? content-evenly?)) (and col? content-evenly?))
layout-gap-row layout-gap-col
0) 0)
col-pad (if (or (and row? space-evenly?) {pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
(and row? space-around?) pad-top (+ (or pad-top 0) row-pad)
(and col? content-evenly?)) pad-right (+ (or pad-right 0) col-pad)
layout-gap-col pad-bottom (+ (or pad-bottom 0) row-pad)
0) pad-left (+ (or pad-left 0) col-pad)
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding layout-points
pad-top (+ (or pad-top 0) row-pad) (layout-content-points bounds parent children objects)]
pad-right (+ (or pad-right 0) col-pad)
pad-bottom (+ (or pad-bottom 0) row-pad)
pad-left (+ (or pad-left 0) col-pad)
layout-points (if (d/not-empty? layout-points)
(layout-content-points bounds parent children objects)] (-> layout-points
(gpo/merge-parent-coords-bounds parent-bounds)
(if (d/not-empty? layout-points) (gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
(-> layout-points ;; Cannot create some bounds from the children so we return the parent's
(gpo/merge-parent-coords-bounds parent-bounds) parent-bounds)))))
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))

View File

@ -12,36 +12,36 @@
(defn layout-content-points (defn layout-content-points
[bounds parent {:keys [row-tracks column-tracks]}] [bounds parent {:keys [row-tracks column-tracks]}]
(let [parent-id (:id parent) (let [parent-id (:id parent)
parent-bounds @(get bounds parent-id) parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
hv #(gpo/start-hv parent-bounds %) (let [hv #(gpo/start-hv parent-bounds %)
vv #(gpo/start-vv parent-bounds %)] vv #(gpo/start-vv parent-bounds %)]
(d/concat-vec (d/concat-vec
(->> row-tracks (->> row-tracks
(mapcat #(vector (:start-p %) (mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (vv (:size %)))))) (gpt/add (:start-p %) (vv (:size %))))))
(->> column-tracks (->> column-tracks
(mapcat #(vector (:start-p %) (mapcat #(vector (:start-p %)
(gpt/add (:start-p %) (hv (:size %))))))))) (gpt/add (:start-p %) (hv (:size %)))))))))))
(defn layout-content-bounds (defn layout-content-bounds
[bounds {:keys [layout-padding] :as parent} layout-data] [bounds {:keys [layout-padding] :as parent} layout-data]
(let [parent-id (:id parent) (let [parent-id (:id parent)
parent-bounds @(get bounds parent-id) parent-bounds (get bounds parent-id)]
(when-let [parent-bounds (some-> parent-bounds deref)]
(let [{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
pad-top (or pad-top 0)
pad-right (or pad-right 0)
pad-bottom (or pad-bottom 0)
pad-left (or pad-left 0)
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding layout-points (layout-content-points bounds parent layout-data)]
pad-top (or pad-top 0)
pad-right (or pad-right 0)
pad-bottom (or pad-bottom 0)
pad-left (or pad-left 0)
layout-points (layout-content-points bounds parent layout-data)] (if (d/not-empty? layout-points)
(-> layout-points
(if (d/not-empty? layout-points) (gpo/merge-parent-coords-bounds parent-bounds)
(-> layout-points (gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
(gpo/merge-parent-coords-bounds parent-bounds) ;; Cannot create some bounds from the children so we return the parent's
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left))) parent-bounds)))))
;; Cannot create some bounds from the children so we return the parent's
parent-bounds)))

View File

@ -31,13 +31,17 @@
(and (ctl/fill-width? child) (and (ctl/fill-width? child)
(ctl/grid-layout? child)) (ctl/grid-layout? child))
(let [children (let [child-bounds-ref (get bounds (:id child))]
(->> (cfh/get-immediate-children objects (:id child)) (if child-bounds-ref
(remove ctl/position-absolute?) (let [children
(map #(vector @(get bounds (:id %)) %))) (->> (cfh/get-immediate-children objects (:id child))
layout-data (gd/calc-layout-data child @(get bounds (:id child)) children bounds objects true)] (remove ctl/position-absolute?)
(max (ctl/child-min-width child) (keep #(when-let [b (get bounds (:id %))]
(gpo/width-points (gb/layout-content-bounds bounds child layout-data)))) [@b %])))
layout-data (gd/calc-layout-data child @child-bounds-ref children bounds objects true)]
(max (ctl/child-min-width child)
(gpo/width-points (gb/layout-content-bounds bounds child layout-data))))
(ctl/child-min-width child)))
(ctl/fill-width? child) (ctl/fill-width? child)
(ctl/child-min-width child) (ctl/child-min-width child)
@ -63,11 +67,15 @@
(let [children (let [children
(->> (cfh/get-immediate-children objects (dm/get-prop child :id)) (->> (cfh/get-immediate-children objects (dm/get-prop child :id))
(remove ctl/position-absolute?) (remove ctl/position-absolute?)
(map (fn [child] [@(get bounds (:id child)) child]))) (keep (fn [c]
(when-let [b (get bounds (:id c))]
[@b c]))))
layout-data (gd/calc-layout-data child (:points child) children bounds objects true) layout-data (gd/calc-layout-data child (:points child) children bounds objects true)
auto-bounds (gb/layout-content-bounds bounds child layout-data)] auto-bounds (gb/layout-content-bounds bounds child layout-data)]
(max (ctl/child-min-height child) (if auto-bounds
(gpo/height-points auto-bounds))) (max (ctl/child-min-height child)
(gpo/height-points auto-bounds))
(ctl/child-min-height child)))
(ctl/fill-height? child) (ctl/fill-height? child)
(ctl/child-min-height child) (ctl/child-min-height child)

View File

@ -0,0 +1,213 @@
;; 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 common-tests.geom-bounds-layout-nil-test
(:require
[app.common.data :as d]
[app.common.geom.bounds-map :as gbm]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes.flex-layout.bounds :as fb]
[app.common.geom.shapes.grid-layout.bounds :as gb]
[app.common.geom.shapes.min-size-layout :as msl]
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[clojure.test :as t]))
;; ---- Helpers ----
(defn- make-rect
[id x y w h]
(-> (cts/setup-shape {:id id
:type :rect
:name (str "rect-" id)
:x x :y y :width w :height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero)))
(defn- make-flex-frame
[id child-ids & {:keys [x y w h dir]
:or {x 0 y 0 w 200 h 200 dir :row}}]
(-> (cts/setup-shape {:id id
:type :frame
:name (str "flex-" id)
:layout :flex
:layout-flex-dir dir
:x x :y y :width w :height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero
:shapes (vec child-ids))))
(defn- make-grid-frame
[id child-ids & {:keys [x y w h dir]
:or {x 0 y 0 w 200 h 200 dir :row}}]
(let [cell-id (uuid/next)]
(-> (cts/setup-shape {:id id
:type :frame
:name (str "grid-" id)
:layout :grid
:layout-grid-dir dir
:layout-grid-columns [{:type :flex :value 1}]
:layout-grid-rows [{:type :flex :value 1}]
:layout-grid-cells
{cell-id {:id cell-id
:row 1
:row-span 1
:column 1
:column-span 1
:shapes (vec child-ids)}}
:layout-padding-type :multiple
:layout-padding {:p1 0 :p2 0 :p3 0 :p4 0}
:layout-gap {:column-gap 0 :row-gap 0}
:x x :y y :width w :height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero
:shapes (vec child-ids)))))
(defn- make-objects
[shapes]
(let [shape-map (into {} (map (fn [s] [(:id s) s]) shapes))]
(reduce-kv (fn [m _id shape]
(if (contains? shape :shapes)
(reduce (fn [m' child-id]
(assoc-in m' [child-id :parent-id] (:id shape)))
m
(:shapes shape))
m))
shape-map
shape-map)))
(defn- bounds-map-from-objects
"Build a bounds map from objects, optionally excluding some IDs."
[objects & {:keys [exclude-ids]}]
(let [full (gbm/objects->bounds-map objects)]
(if (seq exclude-ids)
(apply dissoc full exclude-ids)
full)))
;; ---- Tests for flex layout bounds with nil bounds ----
(t/deftest layout-content-points-with-missing-parent-bounds
(t/testing "layout-content-points returns nil when parent is not in bounds map"
(let [child-id (uuid/next)
parent-id (uuid/next)
child (make-rect child-id 10 10 50 50)
parent (make-flex-frame parent-id [child-id])
objects (make-objects [parent child])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})]
(t/is (nil? (fb/layout-content-points bounds parent [child] objects))))))
(t/deftest layout-content-points-with-missing-child-bounds
(t/testing "layout-content-points skips children with missing bounds"
(let [child1-id (uuid/next)
child2-id (uuid/next)
parent-id (uuid/next)
child1 (make-rect child1-id 10 10 50 50)
child2 (make-rect child2-id 70 10 50 50)
parent (make-flex-frame parent-id [child1-id child2-id])
objects (make-objects [parent child1 child2])
bounds (bounds-map-from-objects objects :exclude-ids #{child1-id})]
(let [result (fb/layout-content-points bounds parent [child1 child2] objects)]
(t/is (some? result))
;; Only child2's bounds should be in the result
(t/is (pos? (count result)))))))
(t/deftest layout-content-bounds-with-missing-parent-bounds
(t/testing "layout-content-bounds returns nil when parent is not in bounds map"
(let [child-id (uuid/next)
parent-id (uuid/next)
child (make-rect child-id 10 10 50 50)
parent (make-flex-frame parent-id [child-id])
objects (make-objects [parent child])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})]
(t/is (nil? (fb/layout-content-bounds bounds parent [child] objects))))))
;; ---- Tests for grid layout bounds with nil bounds ----
(t/deftest grid-layout-content-points-with-missing-parent-bounds
(t/testing "grid layout-content-points returns nil when parent is not in bounds map"
(let [parent-id (uuid/next)
parent (make-grid-frame parent-id [])
objects (make-objects [parent])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})
layout-data {:row-tracks [{:start-p (gpt/point 0 0) :size 100}]
:column-tracks [{:start-p (gpt/point 0 0) :size 100}]}]
(t/is (nil? (gb/layout-content-points bounds parent layout-data))))))
(t/deftest grid-layout-content-bounds-with-missing-parent-bounds
(t/testing "grid layout-content-bounds returns nil when parent is not in bounds map"
(let [parent-id (uuid/next)
parent (make-grid-frame parent-id [])
objects (make-objects [parent])
bounds (bounds-map-from-objects objects :exclude-ids #{parent-id})
layout-data {:row-tracks [{:start-p (gpt/point 0 0) :size 100}]
:column-tracks [{:start-p (gpt/point 0 0) :size 100}]}]
(t/is (nil? (gb/layout-content-bounds bounds parent layout-data))))))
;; ---- Tests for min-size-layout with nil bounds ----
(t/deftest child-min-width-grid-with-missing-child-bounds
(t/testing "child-min-width falls back when grid layout child bounds are missing"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-grid-dir :row
:layout-item-h-sizing :fill))
objects (make-objects [child grandchild])
;; Exclude grandchild from bounds to simulate missing entry
bounds (bounds-map-from-objects objects :exclude-ids #{grandchild-id})
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(let [result (msl/child-min-width child child-bounds bounds objects)]
(t/is (= (ctl/child-min-width child) result))))))
(t/deftest child-min-height-grid-with-missing-child-bounds
(t/testing "child-min-height falls back when grid layout child bounds are missing"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-grid-dir :column
:layout-item-v-sizing :fill))
objects (make-objects [child grandchild])
bounds (bounds-map-from-objects objects :exclude-ids #{grandchild-id})
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(let [result (msl/child-min-height child child-bounds bounds objects)]
(t/is (= (ctl/child-min-height child) result))))))
(t/deftest child-min-width-grid-with-present-child-bounds
(t/testing "child-min-width handles bounded children in a fill-width grid"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-item-h-sizing :fill))
objects (make-objects [child grandchild])
bounds (bounds-map-from-objects objects)
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(t/is (number? (msl/child-min-width child child-bounds bounds objects))))))
(t/deftest child-min-height-grid-with-present-child-bounds
(t/testing "child-min-height handles bounded children in a fill-height grid"
(let [grandchild-id (uuid/next)
child-id (uuid/next)
grandchild (make-rect grandchild-id 0 0 30 30)
child (-> (make-grid-frame child-id [grandchild-id] :w 100 :h 100)
(assoc :layout-item-v-sizing :fill))
objects (make-objects [child grandchild])
bounds (bounds-map-from-objects objects)
child-bounds (grc/rect->points (grc/make-rect 0 0 100 100))]
(t/is (number? (msl/child-min-height child child-bounds bounds objects))))))

View File

@ -22,6 +22,7 @@
[common-tests.files.shapes-builder-test] [common-tests.files.shapes-builder-test]
[common-tests.files.validate-test] [common-tests.files.validate-test]
[common-tests.geom-align-test] [common-tests.geom-align-test]
[common-tests.geom-bounds-layout-nil-test]
[common-tests.geom-bounds-map-test] [common-tests.geom-bounds-map-test]
[common-tests.geom-flex-layout-test] [common-tests.geom-flex-layout-test]
[common-tests.geom-grid-layout-test] [common-tests.geom-grid-layout-test]
@ -95,6 +96,7 @@
'common-tests.files-migrations-test 'common-tests.files-migrations-test
'common-tests.files.validate-test 'common-tests.files.validate-test
'common-tests.geom-align-test 'common-tests.geom-align-test
'common-tests.geom-bounds-layout-nil-test
'common-tests.geom-bounds-map-test 'common-tests.geom-bounds-map-test
'common-tests.geom-flex-layout-test 'common-tests.geom-flex-layout-test
'common-tests.geom-grid-layout-test 'common-tests.geom-grid-layout-test

View File

@ -29,6 +29,7 @@ function isDefined(v) {
} }
function mergeBlockData(block, newData) { function mergeBlockData(block, newData) {
if (!block) return undefined;
let data = block.getData(); let data = block.getData();
for (let key of Object.keys(newData)) { for (let key of Object.keys(newData)) {
@ -176,10 +177,12 @@ export function splitBlockPreservingData(state) {
content = Modifier.splitBlock(content, selection); content = Modifier.splitBlock(content, selection);
const blockData = content.blockMap.get(content.selectionBefore.getStartKey()).getData(); const startKey = content.selectionBefore.getStartKey();
const block = content.blockMap.get(startKey);
const blockData = (block && block.getData()) || new Map();
const blockKey = content.selectionAfter.getStartKey(); const blockKey = content.selectionAfter.getStartKey();
const blockMap = content.blockMap.update(blockKey, (block) => { const blockMap = content.blockMap.update(blockKey, (b) => {
return block.set("data", blockData); return b.set("data", blockData);
}); });
content = content.set("blockMap", blockMap); content = content.set("blockMap", blockMap);
@ -325,6 +328,7 @@ export function updateBlockData(state, blockKey, data) {
const content = state.getCurrentContent(); const content = state.getCurrentContent();
const block = content.getBlockForKey(blockKey); const block = content.getBlockForKey(blockKey);
const newBlock = mergeBlockData(block, data); const newBlock = mergeBlockData(block, data);
if (!newBlock) return state;
const blockData = newBlock.getData(); const blockData = newBlock.getData();

View File

@ -20,10 +20,27 @@
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.websocket :as ws] [app.util.websocket :as ws]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
(def default-timeout 5000) (def default-timeout 5000)
(defn normalize-export
[{:keys [object-id name] :as export}]
(assoc export :name (if (str/blank? name)
(str object-id)
name)))
(defn- normalize-exports
[exports]
(mapv normalize-export exports))
(defn- normalize-export-shapes-params
[{:keys [exports] :as params}]
(cond-> params
(seq exports)
(assoc :exports (normalize-exports exports))))
(defn toggle-detail-visibililty (defn toggle-detail-visibililty
[] []
(ptk/reify ::toggle-detail-visibililty (ptk/reify ::toggle-detail-visibililty
@ -181,104 +198,106 @@
(defn request-simple-export (defn request-simple-export
[{:keys [export]}] [{:keys [export]}]
(ptk/reify ::request-simple-export (let [export (normalize-export export)]
ptk/UpdateEvent (ptk/reify ::request-simple-export
(update [_ state] ptk/UpdateEvent
(cond-> state (update [_ state]
(not (use-wasm-export? state export)) (cond-> state
(update :export assoc :in-progress true :id uuid/zero))) (not (use-wasm-export? state export))
(update :export assoc :in-progress true :id uuid/zero)))
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(if (use-wasm-export? state export) (if (use-wasm-export? state export)
(do (do
(case (:type export) (case (:type export)
:pdf (wasm.exports/export-pdf export) :pdf (wasm.exports/export-pdf export)
(wasm.exports/export-image export)) (wasm.exports/export-image export))
(rx/empty)) (rx/empty))
(let [profile-id (:profile-id state) (let [profile-id (:profile-id state)
params {:exports [export] params (normalize-export-shapes-params {:exports [export]
:profile-id profile-id :profile-id profile-id
:cmd :export-shapes :cmd :export-shapes
:wait true :wait true
:is-wasm (wasm-export-enabled? state)}] :is-wasm (wasm-export-enabled? state)})]
(rx/concat (rx/concat
(dwp/force-persist-and-wait 400) (dwp/force-persist-and-wait 400)
(->> (rp/cmd! :export params) (->> (rp/cmd! :export params)
(rx/map (fn [{:keys [filename mtype uri]}] (rx/map (fn [{:keys [filename mtype uri]}]
(dom/trigger-download-uri filename mtype uri) (dom/trigger-download-uri filename mtype uri)
(clear-export-state uuid/zero))) (clear-export-state uuid/zero)))
(rx/catch (fn [cause] (rx/catch (fn [cause]
(rx/concat (rx/concat
(rx/of (clear-export-state uuid/zero)) (rx/of (clear-export-state uuid/zero))
(rx/throw cause))))))))))) (rx/throw cause))))))))))))
(defn request-multiple-export (defn request-multiple-export
[{:keys [exports cmd name] [{:keys [exports cmd name]
:or {cmd :export-shapes} :or {cmd :export-shapes}
:as params}] :as params}]
(ptk/reify ::request-multiple-export (let [exports (normalize-exports exports)]
ptk/WatchEvent (ptk/reify ::request-multiple-export
(watch [_ state _] ptk/WatchEvent
(let [resource-id (volatile! nil) (watch [_ state _]
profile-id (:profile-id state) (let [resource-id (volatile! nil)
ws-conn (:ws-conn state) profile-id (:profile-id state)
params (cond-> ws-conn (:ws-conn state)
{:exports exports params (cond->
:cmd cmd {:exports exports
:profile-id profile-id :cmd cmd
:force-multiple true :profile-id profile-id
:is-wasm (wasm-export-enabled? state)} :force-multiple true
(some? name) :is-wasm (wasm-export-enabled? state)}
(assoc :name name)) (some? name)
(assoc :name name))
progress-stream progress-stream
(->> (ws/get-rcv-stream ws-conn) (->> (ws/get-rcv-stream ws-conn)
(rx/filter ws/message-event?) (rx/filter ws/message-event?)
(rx/map :payload) (rx/map :payload)
(rx/filter #(= :export-update (:type %))) (rx/filter #(= :export-update (:type %)))
(rx/filter #(= @resource-id (:resource-id %))) (rx/filter #(= @resource-id (:resource-id %)))
(rx/share)) (rx/share))
stopper stopper
(rx/filter #(or (= "ended" (:status %)) (rx/filter #(or (= "ended" (:status %))
(= "error" (:status %))) (= "error" (:status %)))
progress-stream)] progress-stream)]
(swap! st/ongoing-tasks conj :export) (swap! st/ongoing-tasks conj :export)
(rx/merge (rx/merge
;; Force that all data is persisted; best effort. ;; Force that all data is persisted; best effort.
(rx/of ::dwp/force-persist) (rx/of ::dwp/force-persist)
;; Launch the exportation process and stores the resource id ;; Launch the exportation process and stores the resource id
;; locally. ;; locally.
(->> (rp/cmd! :export params) (->> (rp/cmd! :export params)
(rx/map (fn [{:keys [id] :as resource}] (rx/map (fn [{:keys [id] :as resource}]
(vreset! resource-id id) (vreset! resource-id id)
(initialize-export-status exports cmd resource)))) (initialize-export-status exports cmd resource))))
;; We proceed to update the export state with incoming ;; We proceed to update the export state with incoming
;; progress updates. We delay the stopper for give some time ;; progress updates. We delay the stopper for give some time
;; to update the status with ended or errored status before ;; to update the status with ended or errored status before
;; close the stream. ;; close the stream.
(->> progress-stream (->> progress-stream
(rx/map update-export-status) (rx/map update-export-status)
(rx/take-until (rx/delay 500 stopper)) (rx/take-until (rx/delay 500 stopper))
(rx/finalize (fn [] (rx/finalize (fn []
(swap! st/ongoing-tasks disj :export)))) (swap! st/ongoing-tasks disj :export))))
;; We hide need to hide the ui elements of the export after ;; We hide need to hide the ui elements of the export after
;; some interval. We also delay a little bit more the stopper ;; some interval. We also delay a little bit more the stopper
;; for ensure that after some security time, the stream is ;; for ensure that after some security time, the stream is
;; completely closed. ;; completely closed.
(->> progress-stream (->> progress-stream
(rx/filter #(= "ended" (:status %))) (rx/filter #(= "ended" (:status %)))
(rx/take 1) (rx/take 1)
(rx/delay default-timeout) (rx/delay default-timeout)
(rx/map #(clear-export-state @resource-id)) (rx/map #(clear-export-state @resource-id))
(rx/take-until (rx/delay 6000 stopper)))))))) (rx/take-until (rx/delay 6000 stopper)))))))))
(defn request-export (defn request-export
[{:keys [exports] :as params}] [{:keys [exports] :as params}]

View File

@ -34,6 +34,7 @@
[app.config :as cf] [app.config :as cf]
[app.main.data.changes :as dch] [app.main.data.changes :as dch]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.exports.assets :as de]
[app.main.data.exports.wasm :as wasm.exports] [app.main.data.exports.wasm :as wasm.exports]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.notifications :as ntf] [app.main.data.notifications :as ntf]
@ -1147,16 +1148,16 @@
page-id (:current-page-id state) page-id (:current-page-id state)
selected (first (dsh/lookup-selected state)) selected (first (dsh/lookup-selected state))
export {:file-id file-id export (de/normalize-export {:file-id file-id
:page-id page-id :page-id page-id
:object-id selected :object-id selected
;; webp would be preferrable, but PNG is the most supported image MIME type by clipboard APIs. ;; webp would be preferrable, but PNG is the most supported image MIME type by clipboard APIs.
:type :png :type :png
;; Always use 2 to ensure good enough quality for wireframes. ;; Always use 2 to ensure good enough quality for wireframes.
:scale 2 :scale 2
:suffix "" :suffix ""
:enabled true :enabled true
:name ""} :name ""})
;; Create a deferred promise immediately, before any async operations. ;; Create a deferred promise immediately, before any async operations.
;; Registering the clipboard write NOW preserves the user-gesture security ;; Registering the clipboard write NOW preserves the user-gesture security

View File

@ -9,6 +9,7 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.math :as mth]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.color :as clr] [app.common.types.color :as clr]
[app.common.types.fills :as types.fills] [app.common.types.fills :as types.fills]
@ -950,7 +951,8 @@
(or (not cap-stops?) (< (count stops) types.fills/MAX-GRADIENT-STOPS))] (or (not cap-stops?) (< (count stops) types.fills/MAX-GRADIENT-STOPS))]
(if can-add-stop? (if can-add-stop?
(let [new-stop (-> (clr/interpolate-gradient stops offset) (let [offset (mth/clamp offset 0 1)
new-stop (-> (clr/interpolate-gradient stops offset)
(split-color-components)) (split-color-components))
stops (conj stops new-stop) stops (conj stops new-stop)
stops (into [] (sort-by :offset stops)) stops (into [] (sort-by :offset stops))
@ -973,7 +975,8 @@
stops (mapv split-color-components stops (mapv split-color-components
(if cap-stops? (if cap-stops?
(take types.fills/MAX-GRADIENT-STOPS stops) (take types.fills/MAX-GRADIENT-STOPS stops)
stops))] stops))
stops (mapv #(update % :offset (fn [o] (mth/clamp o 0 1))) stops)]
(-> state (-> state
(assoc :current-color (get stops stop)) (assoc :current-color (get stops stop))
(assoc :stops stops)))))))) (assoc :stops stops))))))))

View File

@ -1571,7 +1571,12 @@
(as-> libraries-to-load $ (as-> libraries-to-load $
(remove loaded-libraries $) (remove loaded-libraries $)
(conj $ library-id) (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)) (rx/of (ptk/reify ::attach-library-finished))
(when (pos? variants-count) (when (pos? variants-count)
(->> (rp/cmd! :get-library-usage {:file-id library-id}) (->> (rp/cmd! :get-library-usage {:file-id library-id})

View File

@ -107,8 +107,8 @@
(defn format-last-events (defn format-last-events
"Render the `last-events` buffer as a multi-line string with the "Render the `last-events` buffer as a multi-line string with the
wall-clock time of each event and the delta (ms) since the previous wall-clock time of each event and the delta (ms) since the previous
entry. The first entry has no delta. Useful for embedding in error entry. The delta column is right-padded to 10 chars so the event
reports." names align. Useful for embedding in error reports."
([] (format-last-events @last-events)) ([] (format-last-events @last-events))
([events] ([events]
(let [lines (let [lines
@ -117,13 +117,14 @@
out (transient [])] out (transient [])]
(if xs (if xs
(let [{:keys [name t]} (first xs) (let [{:keys [name t]} (first xs)
iso (ct/format-inst t :iso) iso (ct/format-inst t :iso)
tail (if prev-t delta (if prev-t
(str " (+" (ct/diff-ms prev-t t) "ms)") (str "(+" (ct/diff-ms prev-t t) "ms)")
"")] "(+0ms)")
delta-pad (str/pad delta {:length 10 :type :right})]
(recur t (recur t
(next xs) (next xs)
(conj! out (str iso tail " " name)))) (conj! out (str iso " " delta-pad " " name))))
(persistent! out)))] (persistent! out)))]
(str/join "\n" lines)))) (str/join "\n" lines))))

View File

@ -18,6 +18,7 @@
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.timers :as tm] [app.util.timers :as tm]
[beicon.v2.core :as rx]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(def ^:private xf:options (def ^:private xf:options
@ -229,8 +230,11 @@
(partial ug/unlisten "penpot:context-menu:open" on-event))) (partial ug/unlisten "penpot:context-menu:open" on-event)))
(mf/with-effect [ids] (mf/with-effect [ids]
(tm/schedule-on-idle (let [handle (tm/schedule
#(dom/focus! (dom/get-element (first ids))))) (fn []
(some-> (dom/get-element (first ids))
(dom/focus!))))]
#(rx/dispose! handle)))
(when (some? levels) (when (some? levels)
[:> dropdown-content* props [:> dropdown-content* props

View File

@ -11,6 +11,7 @@
[app.util.globals :as globals] [app.util.globals :as globals]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.timers :as tm] [app.util.timers :as tm]
[beicon.v2.core :as rx]
[goog.events :as events] [goog.events :as events]
[rumext.v2 :as mf]) [rumext.v2 :as mf])
(:import goog.events.EventType)) (:import goog.events.EventType))
@ -45,9 +46,10 @@
(fn [] (fn []
(let [keys [(events/listen globals/document EventType.CLICK on-click) (let [keys [(events/listen globals/document EventType.CLICK on-click)
(events/listen globals/document EventType.CONTEXTMENU on-click) (events/listen globals/document EventType.CONTEXTMENU on-click)
(events/listen globals/document EventType.KEYUP on-keyup)]] (events/listen globals/document EventType.KEYUP on-keyup)]
(tm/schedule #(mf/set-ref-val! listening-ref true)) timer (tm/schedule #(mf/set-ref-val! listening-ref true))]
#(run! events/unlistenByKey keys)))] #(do (rx/dispose! timer)
(run! events/unlistenByKey keys))))]
(mf/use-effect on-mount) (mf/use-effect on-mount)
children)) children))

View File

@ -560,28 +560,29 @@
on-paste on-paste
(mf/use-fn (mf/use-fn
(fn [event] (fn [event]
(let [paste-data (-> event .-clipboardData (.getData "text"))] (when-let [clipboard-data (.-clipboardData event)]
(when (and (string? paste-data) (let [paste-data (.getData clipboard-data "text")]
(re-find #"[,\s]" paste-data)) (when (and (string? paste-data)
(dom/prevent-default event) (re-find #"[,\s]" paste-data))
(dom/stop-propagation event) (dom/prevent-default event)
(dom/stop-propagation event)
;; Mark as touched ;; Mark as touched
(swap! form assoc-in [:touched input-name] true) (swap! form assoc-in [:touched input-name] true)
;; Split pasted text by commas and/or whitespace, add each valid part ;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+") (let [parts (->> (str/split paste-data #",|\s+")
(map str/trim) (map str/trim)
(remove str/empty?))] (remove str/empty?))]
(doseq [part parts] (doseq [part parts]
(when (valid-item-fn part) (when (valid-item-fn part)
(swap! items conj-dedup {:text part (swap! items conj-dedup {:text part
:valid true :valid true
:caution (caution-item-fn part)}))) :caution (caution-item-fn part)})))
;; Reset input value and mark as untouched after successful paste ;; Reset input value and mark as untouched after successful paste
(reset! value "") (reset! value "")
(swap! form assoc-in [:touched input-name] false)))))) (swap! form assoc-in [:touched input-name] false)))))))
on-blur on-blur
(mf/use-fn (mf/use-fn

View File

@ -40,6 +40,7 @@
[app.main.ui.ds.buttons.button :refer [button*]] [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.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] [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.icons :as deprecated-icon]
[app.main.ui.nitrate.nitrate-form] [app.main.ui.nitrate.nitrate-form]
[app.util.dom :as dom] [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 "penpot-logo-icon")
(def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle") (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/defc sidebar-project*
{::mf/private true} {::mf/private true}
[{:keys [item is-selected]}] [{:keys [item is-selected]}]
@ -112,6 +121,8 @@
project-id (get item :id) project-id (get item :id)
focus-timer-ref (use-focus-timer-ref)
on-click on-click
(mf/use-fn (mf/use-fn
(mf/deps project-id) (mf/deps project-id)
@ -123,14 +134,9 @@
(mf/deps project-id) (mf/deps project-id)
(fn [event] (fn [event]
(when (kbd/enter? event) (when (kbd/enter? event)
(st/emit! (schedule-focus-by-id! focus-timer-ref (str project-id))
(dcm/go-to-dashboard-files :project-id project-id)) (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")))))))
on-menu-click on-menu-click
(mf/use-fn (mf/use-fn
@ -228,6 +234,8 @@
focused? (mf/use-state false) focused? (mf/use-state false)
emit! (mf/use-memo #(f/debounce st/emit! 500)) emit! (mf/use-memo #(f/debounce st/emit! 500))
focus-timer-ref (use-focus-timer-ref)
on-search-blur on-search-blur
(mf/use-fn (mf/use-fn
(fn [_] (fn [_]
@ -254,13 +262,7 @@
(mf/use-fn (mf/use-fn
(fn [e] (fn [e]
(when (kbd/enter? e) (when (kbd/enter? e)
(ts/schedule (schedule-focus-by-id! focus-timer-ref "dashboard-search-title")
(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")))))
(dom/prevent-default e) (dom/prevent-default e)
(dom/stop-propagation e)))) (dom/stop-propagation e))))
@ -948,6 +950,8 @@
nitrate? (contains? cf/flags :nitrate) nitrate? (contains? cf/flags :nitrate)
focus-timer-ref (use-focus-timer-ref)
go-projects go-projects
(mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent))) (mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent)))
@ -957,12 +961,7 @@
(fn [] (fn []
(st/emit! (st/emit!
(dcm/go-to-dashboard-recent :team-id team-id)) (dcm/go-to-dashboard-recent :team-id team-id))
(ts/schedule (schedule-focus-by-id! focus-timer-ref "dashboard-projects-title")))
(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"))))))
go-fonts go-fonts
(mf/use-fn (mf/use-fn
@ -975,13 +974,7 @@
(fn [] (fn []
(st/emit! (st/emit!
(dcm/go-to-dashboard-fonts :team-id team-id)) (dcm/go-to-dashboard-fonts :team-id team-id))
(ts/schedule (schedule-focus-by-id! focus-timer-ref "dashboard-fonts-title")))
(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")))))))
go-drafts go-drafts
(mf/use-fn (mf/use-fn
@ -994,12 +987,7 @@
(mf/deps team-id default-project-id) (mf/deps team-id default-project-id)
(fn [] (fn []
(st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id)) (st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id))
(ts/schedule (schedule-focus-by-id! focus-timer-ref "dashboard-drafts-title")))
(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"))))))
go-libs go-libs
(mf/use-fn (mf/use-fn
@ -1012,13 +1000,7 @@
(fn [] (fn []
(st/emit! (st/emit!
(dcm/go-to-dashboard-libraries :team-id team-id)) (dcm/go-to-dashboard-libraries :team-id team-id))
(ts/schedule (schedule-focus-by-id! focus-timer-ref "dashboard-libraries-title")))
(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")))))))
pinned-projects pinned-projects
(mf/with-memo [projects] (mf/with-memo [projects]

View File

@ -322,25 +322,26 @@
(let [trigger-el (mf/ref-val trigger-ref) (let [trigger-el (mf/ref-val trigger-ref)
tooltip-el (mf/ref-val tooltip-ref)] tooltip-el (mf/ref-val tooltip-ref)]
(when (and trigger-el tooltip-el) (when (and trigger-el tooltip-el)
(ts/raf (let [raf-id (ts/raf
(fn [] (fn []
(let [origin-brect (dom/get-bounding-rect trigger-el) (let [origin-brect (dom/get-bounding-rect trigger-el)
tooltip-brect (dom/get-bounding-rect tooltip-el) tooltip-brect (dom/get-bounding-rect tooltip-el)
window-size (dom/get-window-size)] window-size (dom/get-window-size)]
(when-let [[new-placement placement-rect] (when-let [[new-placement placement-rect]
(find-matching-placement (find-matching-placement
placement placement
tooltip-brect tooltip-brect
origin-brect origin-brect
window-size window-size
offset)] offset)]
(dom/set-css-property! tooltip-el "inset-block-start" (dom/set-css-property! tooltip-el "inset-block-start"
(str (:top placement-rect) "px")) (str (:top placement-rect) "px"))
(dom/set-css-property! tooltip-el "inset-inline-start" (dom/set-css-property! tooltip-el "inset-inline-start"
(str (:left placement-rect) "px")) (str (:left placement-rect) "px"))
(when (not= new-placement placement) (when (not= new-placement placement)
(reset! placement* new-placement))))))))))) (reset! placement* new-placement))))))]
#(ts/cancel-af! raf-id)))))))
[:> :div props [:> :div props
children children

View File

@ -121,28 +121,29 @@
on-paste on-paste
(mf/use-fn (mf/use-fn
(fn [event] (fn [event]
(let [paste-data (-> event .-clipboardData (.getData "text"))] (when-let [clipboard-data (.-clipboardData event)]
(when (and (string? paste-data) (let [paste-data (.getData clipboard-data "text")]
(re-find #"[,\s]" paste-data)) (when (and (string? paste-data)
(dom/prevent-default event) (re-find #"[,\s]" paste-data))
(dom/stop-propagation event) (dom/prevent-default event)
(dom/stop-propagation event)
;; Mark as touched ;; Mark as touched
(swap! form assoc-in [:touched name] true) (swap! form assoc-in [:touched name] true)
;; Split pasted text by commas and/or whitespace, add each valid part ;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+") (let [parts (->> (str/split paste-data #",|\s+")
(map str/trim) (map str/trim)
(remove str/empty?))] (remove str/empty?))]
(doseq [part parts] (doseq [part parts]
(when (valid-item-fn part) (when (valid-item-fn part)
(swap! items conj-dedup {:text part (swap! items conj-dedup {:text part
:valid true :valid true
:caution (caution-item-fn part)}))) :caution (caution-item-fn part)})))
;; Reset input value and mark as untouched after successful paste ;; Reset input value and mark as untouched after successful paste
(reset! value "") (reset! value "")
(swap! form assoc-in [:touched name] false)))))) (swap! form assoc-in [:touched name] false)))))))
on-blur on-blur
(mf/use-fn (mf/use-fn

View File

@ -281,6 +281,16 @@
(mf/set-ref-val! ref val)) (mf/set-ref-val! ref val))
(mf/ref-val ref))) (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 ;; FIXME: rename to use-focus-objects
(defn with-focus-objects (defn with-focus-objects
([objects] ([objects]

View File

@ -44,7 +44,6 @@
[app.main.ui.workspace.webgl-unavailable-modal] [app.main.ui.workspace.webgl-unavailable-modal]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[goog.events :as events] [goog.events :as events]
[okulary.core :as l] [okulary.core :as l]
@ -177,7 +176,7 @@
(mf/with-effect [] (mf/with-effect []
(let [focus-out #(st/emit! (dw/workspace-focus-lost)) (let [focus-out #(st/emit! (dw/workspace-focus-lost))
key (events/listen globals/window "blur" focus-out)] key (events/listen js/window "blur" focus-out)]
(partial events/unlistenByKey key))) (partial events/unlistenByKey key)))
(mf/with-effect [file-id page-id] (mf/with-effect [file-id page-id]
@ -252,7 +251,7 @@
(let [handle-wasm-render (let [handle-wasm-render
(fn [_] (fn [_]
(reset! first-frame-rendered? true)) (reset! first-frame-rendered? true))
listener-key (events/listen globals/document "penpot:wasm:render" handle-wasm-render)] listener-key (events/listen js/document "penpot:wasm:render" handle-wasm-render)]
(fn [] (fn []
(events/unlistenByKey listener-key)))) (events/unlistenByKey listener-key))))

View File

@ -233,6 +233,7 @@
(mf/deps on-add-stop-preview) (mf/deps on-add-stop-preview)
(fn [^js e] (fn [^js e]
(let [offset (-> (event->offset e) (let [offset (-> (event->offset e)
(mth/clamp 0 1)
(mth/precision 2))] (mth/precision 2))]
(when on-add-stop-preview (when on-add-stop-preview
(on-add-stop-preview offset))))) (on-add-stop-preview offset)))))

View File

@ -61,7 +61,7 @@
nil))) nil)))
(defn- styles-fn [shape styles content] (defn- styles-fn [shape styles content]
(let [data (if (= (.getText ^js content) "") (let [data (if (and content (= (.getText ^js content) ""))
(-> ^js (.getData content) (-> ^js (.getData content)
(.toJS) (.toJS)
(js->clj :keywordize-keys true)) (js->clj :keywordize-keys true))

View File

@ -105,14 +105,14 @@
(mf/use-fn (mf/use-fn
(fn [^js event] (fn [^js event]
(dom/prevent-default event) (dom/prevent-default event)
(let [clipboard-data (.-clipboardData event) (when-let [clipboard-data (.-clipboardData event)]
text (.getData clipboard-data "text/plain")] (let [text (.getData clipboard-data "text/plain")]
(when (and text (seq text)) (when (and text (seq text))
(text-editor/text-editor-insert-text text) (text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!) (sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paste")) (wasm.api/request-render "text-paste"))))
(when-let [node (mf/ref-val contenteditable-ref)] (when-let [node (mf/ref-val contenteditable-ref)]
(set! (.-textContent node) ""))))) (set! (.-textContent node) ""))))
on-copy on-copy
(mf/use-fn (mf/use-fn

View File

@ -189,6 +189,7 @@
lv (-> (gpt/to-vec from-p to-p) (gpt/unit)) lv (-> (gpt/to-vec from-p to-p) (gpt/unit))
nv (gpt/normal-left lv) nv (gpt/normal-left lv)
offset (-> (gsp/project-t position [from-p to-p] nv) offset (-> (gsp/project-t position [from-p to-p] nv)
(mth/clamp 0 1)
(mth/precision 2)) (mth/precision 2))
new-stop (cc/interpolate-gradient stops offset) new-stop (cc/interpolate-gradient stops offset)
stops (conj stops new-stop) stops (conj stops new-stop)

View File

@ -56,51 +56,52 @@
(defn process-pointer-move (defn process-pointer-move
[viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y] [viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y]
(when-let [image-data (mf/ref-val canvas-image-data)] (when viewport-node
(when-let [zoom-view-node (dom/get-element "picker-detail")] (when-let [image-data (mf/ref-val canvas-image-data)]
(when-not (mf/ref-val zoom-view-context) (when-let [zoom-view-node (dom/get-element "picker-detail")]
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d"))) (when-not (mf/ref-val zoom-view-context)
(let [canvas-width 260 (mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
canvas-height 140 (let [canvas-width 260
{brx :left bry :top} (dom/get-bounding-rect viewport-node) canvas-height 140
{brx :left bry :top} (dom/get-bounding-rect viewport-node)
x (mth/floor (- client-x brx)) x (mth/floor (- client-x brx))
y (mth/floor (- client-y bry)) y (mth/floor (- client-y bry))
img-width (unchecked-get image-data "width") img-width (unchecked-get image-data "width")
img-height (unchecked-get image-data "height") img-height (unchecked-get image-data "height")
zoom-context (mf/ref-val zoom-view-context) zoom-context (mf/ref-val zoom-view-context)
sx (- x 32) sx (- x 32)
sy (if (cfg/check-browser? :safari) y (- y 17)) sy (if (cfg/check-browser? :safari) y (- y 17))
sw 65 sw 65
sh 35 sh 35
dx 0 dx 0
dy 0 dy 0
dw canvas-width dw canvas-width
dh canvas-height] dh canvas-height]
(when (obj/get zoom-context "imageSmoothingEnabled") (when (obj/get zoom-context "imageSmoothingEnabled")
(obj/set! zoom-context "imageSmoothingEnabled" false)) (obj/set! zoom-context "imageSmoothingEnabled" false))
(.clearRect zoom-context 0 0 canvas-width canvas-height) (.clearRect zoom-context 0 0 canvas-width canvas-height)
(.drawImage zoom-context canvas sx sy sw sh dx dy dw dh) (.drawImage zoom-context canvas sx sy sw sh dx dy dw dh)
;; Only pick color when cursor is within canvas bounds to avoid garbage pixels ;; Only pick color when cursor is within canvas bounds to avoid garbage pixels
(when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height)) (when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height))
(let [offset (* (+ (* y img-width) x) 4) (let [offset (* (+ (* y img-width) x) 4)
rgba (unchecked-get image-data "data") rgba (unchecked-get image-data "data")
r (d/check-num (obj/get rgba (+ 0 offset)) 255) r (d/check-num (obj/get rgba (+ 0 offset)) 255)
g (d/check-num (obj/get rgba (+ 1 offset)) 255) g (d/check-num (obj/get rgba (+ 1 offset)) 255)
b (d/check-num (obj/get rgba (+ 2 offset)) 255) b (d/check-num (obj/get rgba (+ 2 offset)) 255)
a (d/check-num (obj/get rgba (+ 3 offset)) 255) a (d/check-num (obj/get rgba (+ 3 offset)) 255)
color [r g b a]] color [r g b a]]
;; Store latest color synchronously so the click handler always reads ;; Store latest color synchronously so the click handler always reads
;; the correct pixel even before the rAF fires (fixes race condition) ;; the correct pixel even before the rAF fires (fixes race condition)
(mf/set-ref-val! last-picked-color color) (mf/set-ref-val! last-picked-color color)
(timers/raf (timers/raf
(fn [] (fn []
(st/emit! (dwc/pick-color color)))))))))) (st/emit! (dwc/pick-color color)))))))))))
(mf/defc pixel-overlay* (mf/defc pixel-overlay*
@ -260,18 +261,19 @@
(defn- viewport->canvas-coords (defn- viewport->canvas-coords
"Maps client (viewport) coordinates to device-pixel canvas coordinates." "Maps client (viewport) coordinates to device-pixel canvas coordinates."
[viewport-node client-x client-y] [viewport-node client-x client-y]
(let [{brx :left bry :top} (dom/get-bounding-rect viewport-node) (when viewport-node
dpr (wasm.api/get-dpr) (let [{brx :left bry :top} (dom/get-bounding-rect viewport-node)
x (mth/floor (- client-x brx)) dpr (wasm.api/get-dpr)
y (mth/floor (- client-y bry))] x (mth/floor (- client-x brx))
[(mth/floor (* x dpr)) y (mth/floor (- client-y bry))]
(mth/floor (* y dpr))])) [(mth/floor (* x dpr))
(mth/floor (* y dpr))])))
(defn process-pointer-move-wasm (defn process-pointer-move-wasm
"Updates the magnifier loupe with the canvas region under the cursor. The "Updates the magnifier loupe with the canvas region under the cursor. The
actual color is only read on click (see `pick-color-at-wasm`)." actual color is only read on click (see `pick-color-at-wasm`)."
[viewport-node canvas zoom-view-context client-x client-y] [viewport-node canvas zoom-view-context client-x client-y]
(when canvas (when (and canvas viewport-node)
(when-let [zoom-view-node (dom/get-element "picker-detail")] (when-let [zoom-view-node (dom/get-element "picker-detail")]
(when-not (mf/ref-val zoom-view-context) (when-not (mf/ref-val zoom-view-context)
(mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d"))) (mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d")))
@ -309,7 +311,7 @@
the correct color even on GPUs where a raw WebGL `readPixels` returned the correct color even on GPUs where a raw WebGL `readPixels` returned
values with their byte order swapped." values with their byte order swapped."
[viewport-node canvas client-x client-y] [viewport-node canvas client-x client-y]
(when canvas (when (and canvas viewport-node)
(let [[canvas-x canvas-y] (viewport->canvas-coords viewport-node client-x client-y) (let [[canvas-x canvas-y] (viewport->canvas-coords viewport-node client-x client-y)
img-width (.-width canvas) img-width (.-width canvas)
img-height (.-height canvas)] img-height (.-height canvas)]
@ -370,7 +372,7 @@
handle-draw-picker-canvas handle-draw-picker-canvas
(mf/use-callback (mf/use-callback
(fn [] (fn []
(when canvas (when (and canvas viewport-node)
;; Read current mouse position from ref so the loupe refreshes on ;; Read current mouse position from ref so the loupe refreshes on
;; each render even without a mouse-move. ;; each render even without a mouse-move.
(let [{mx :x my :y} (mf/ref-val initial-mouse-pos)] (let [{mx :x my :y} (mf/ref-val initial-mouse-pos)]

View File

@ -7,62 +7,75 @@
(ns app.main.ui.workspace.viewport.viewport-ref (ns app.main.ui.workspace.viewport.viewport-ref
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.mouse :as mse] [app.util.mouse :as mse]
[goog.events :as events] [rumext.v2 :as mf]))
[rumext.v2 :as mf])
(:import goog.events.EventType))
(defonce viewport-ref (atom nil)) (defonce viewport-ref (atom nil))
(defonce current-observer (atom nil))
(defonce viewport-brect (atom nil)) (defonce viewport-brect (atom nil))
(defn init-observer (defn- init-observer
[node on-change-bounds] [node]
(let [on-change-bounds
(fn [_]
(let [brect (dom/get-bounding-rect node)
brect (gpt/point (d/parse-integer (:left brect))
(d/parse-integer (:top brect)))]
(reset! viewport-brect brect)))
(let [observer (js/ResizeObserver. on-change-bounds)] observer
(when (some? @current-observer) (js/ResizeObserver. on-change-bounds)]
(.disconnect @current-observer))
(reset! current-observer observer) (.observe observer node)
observer))
(when (some? node)
(.observe observer node))))
(defn on-change-bounds
[_]
(when @viewport-ref
(let [brect (dom/get-bounding-rect @viewport-ref)
brect (gpt/point (d/parse-integer (:left brect))
(d/parse-integer (:top brect)))]
(reset! viewport-brect brect))))
(defn create-viewport-ref (defn create-viewport-ref
[] []
(let [ref (mf/use-ref nil)] (let [node-ref (mf/use-ref nil)
[ref handler-ref (mf/use-ref nil)
(mf/use-memo observer-ref (mf/use-ref nil)
#(fn [node] callback (mf/use-fn
(mf/set-ref-val! ref node) (fn [node]
(reset! viewport-ref node) ;; Dispose all previous resources
(when (some? node) (when-let [observer (mf/ref-val observer-ref)]
(events/listen node EventType.MOUSELEAVE (fn [] (st/emit! (mse/->BlurEvent))))) (.disconnect ^js observer)
(init-observer node on-change-bounds)))])) (mf/set-ref-val! observer-ref nil))
(when-let [handler (mf/ref-val handler-ref)]
(when-let [node (mf/ref-val node-ref)]
(.removeEventListener ^js node "mouseleave" handler)
(mf/set-ref-val! handler-ref nil)))
;; Reset the ref values to the current node (can be nil)
(mf/set-ref-val! node-ref node)
(reset! viewport-ref node)
(when (some? node)
(let [handler (fn [] (st/emit! (mse/->BlurEvent)))
observer (init-observer node)]
(.addEventListener ^js node "mouseleave" handler)
(mf/set-ref-val! handler-ref handler)
(mf/set-ref-val! observer-ref observer)))))]
[node-ref callback]))
(defn point->viewport (defn point->viewport
[pt] [pt]
(let [zoom (dm/get-in @st/state [:workspace-local :zoom] 1)] (let [zoom (d/nilv @refs/selected-zoom 1)
(when (and (some? @viewport-ref) viewport-node @viewport-ref
(some? @viewport-brect)) viewport-brect @viewport-brect]
(let [vbox (.. ^js @viewport-ref -viewBox -baseVal)
brect @viewport-brect
box (gpt/point (.-x vbox) (.-y vbox))
zoom (gpt/point zoom)]
(-> (gpt/subtract pt brect) (when (and (some? viewport-brect)
(some? viewport-node))
(let [vbox (.. ^js viewport-node -viewBox -baseVal)
box (gpt/point (.-x vbox) (.-y vbox))
zoom (gpt/point zoom)]
(-> (gpt/subtract pt viewport-brect)
(gpt/divide zoom) (gpt/divide zoom)
(gpt/add box)))))) (gpt/add box))))))
@ -71,8 +84,8 @@
Unlike point->viewport, this does NOT convert to canvas coordinates - Unlike point->viewport, this does NOT convert to canvas coordinates -
it just subtracts the viewport's bounding rect offset." it just subtracts the viewport's bounding rect offset."
[pt] [pt]
(when (some? @viewport-brect) (when-let [brect @viewport-brect]
(gpt/subtract pt @viewport-brect))) (gpt/subtract pt brect)))
(defn inside-viewport? (defn inside-viewport?
[target] [target]

View File

@ -34,6 +34,7 @@
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.main.data.exports.assets :as de]
[app.main.data.exports.wasm :as wasm.exports] [app.main.data.exports.wasm :as wasm.exports]
[app.main.data.persistence :as dwp] [app.main.data.persistence :as dwp]
[app.main.data.plugins :as dp] [app.main.data.plugins :as dp]
@ -1547,13 +1548,13 @@
:profile-id (:profile-id @st/state) :profile-id (:profile-id @st/state)
:wait true :wait true
:is-wasm false :is-wasm false
:exports [{:file-id file-id :exports [(de/normalize-export {:file-id file-id
:page-id page-id :page-id page-id
:object-id id :object-id id
:name (:name shape) :name (:name shape)
:type (:type value :png) :type (:type value :png)
:suffix (:suffix value "") :suffix (:suffix value "")
:scale (:scale value 1)}]}] :scale (:scale value 1)})]}]
(js/Promise. (js/Promise.
(fn [resolve reject] (fn [resolve reject]
;; The exporter renders the file from its persisted ;; The exporter renders the file from its persisted

View File

@ -699,6 +699,13 @@
(when (some? node) (when (some? node)
(.setAttribute node attr value))) (.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! (defn set-style!
[^js node ^string style value] [^js node ^string style value]
(when (some? node) (when (some? node)

View File

@ -115,13 +115,13 @@
([e] ([e]
(get-data e "penpot/data")) (get-data e "penpot/data"))
([e data-type] ([e data-type]
(let [dt (.-dataTransfer e) (when-let [dt (.-dataTransfer e)]
data (.getData dt data-type)] (let [data (.getData dt data-type)]
(cond-> data (cond-> data
(and (some? data) (not= data "") (and (some? data) (not= data "")
(or (str/starts-with? data-type "penpot") (or (str/starts-with? data-type "penpot")
(= data-type "application/json"))) (= data-type "application/json")))
(t/decode-str))))) (t/decode-str))))))
(defn get-files (defn get-files
[e] [e]

View File

@ -60,12 +60,14 @@
(defn get-editor-block-data (defn get-editor-block-data
[block] [block]
(-> (.getData ^js block) (when (some? block)
(immutable-map->map))) (-> (.getData ^js block)
(immutable-map->map))))
(defn get-editor-block-type (defn get-editor-block-type
[block] [block]
(.getType ^js block)) (when (some? block)
(.getType ^js block)))
(defn get-editor-current-block-data (defn get-editor-current-block-data
[state] [state]

View File

@ -82,6 +82,10 @@
(defn read-as-text (defn read-as-text
[entry] [entry]
(when (nil? entry)
(ex/raise :type :assertion
:code :invalid-entry
:hint "cannot read zip entry: entry is nil"))
(let [writer (new zip/TextWriter)] (let [writer (new zip/TextWriter)]
(.getData entry writer))) (.getData entry writer)))

View File

@ -7,6 +7,7 @@
(ns app.worker.import (ns app.worker.import
(:refer-clojure :exclude [resolve]) (:refer-clojure :exclude [resolve])
(:require (:require
[app.common.exceptions :as ex]
[app.common.json :as json] [app.common.json :as json]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.schema :as sm] [app.common.schema :as sm]
@ -44,10 +45,15 @@
(def conjv (fnil conj [])) (def conjv (fnil conj []))
(defn- read-zip-manifest (defn read-zip-manifest
[zip-reader] [zip-reader]
(->> (rx/from (uz/get-entry zip-reader "manifest.json")) (->> (rx/from (uz/get-entry zip-reader "manifest.json"))
(rx/mapcat uz/read-as-text) (rx/mapcat (fn [entry]
(if (nil? entry)
(rx/throw (ex/error :type :validation
:code :invalid-penpot-file
:hint "Not a valid Penpot file: manifest.json is missing"))
(uz/read-as-text entry))))
(rx/map json/decode))) (rx/map json/decode)))
(defn slurp-uri (defn slurp-uri

View File

@ -0,0 +1,99 @@
;; 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 frontend-tests.data.exports-assets-test
(:require
[app.common.uuid :as uuid]
[app.main.data.exports.assets :as de]
[app.main.data.persistence :as dwp]
[app.main.repo :as repo]
[app.main.store :as st]
[app.util.dom :as dom]
[app.util.websocket :as ws]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.events :as the]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(def ^:private export {:id (uuid/next)
:object-id (uuid/next)
:type :png
:suffix ""
:scale 1})
(defn- export-with-name
[name]
(merge export {:name name}))
(defn- test-state
[]
{:profile-id (:id export)
:ws-conn nil})
(t/deftest normalize-export-preserves-existing-name
(t/is (= (export-with-name "Layer 1")
(de/normalize-export (export-with-name "Layer 1")))))
(t/deftest normalize-export-replaces-nil-name-with-object-id
(t/is (= (export-with-name (str (:object-id export)))
(de/normalize-export (assoc export :name nil)))))
(t/deftest normalize-export-replaces-empty-name-with-object-id
(t/is (= (export-with-name (str (:object-id export)))
(de/normalize-export (assoc export :name "")))))
(t/deftest request-simple-export-sends-normalized-export
(t/async done
(let [export (export-with-name "")
observed (atom nil)]
(mock/with-mocks {repo/cmd! (mock/stub (fn [_ params]
(reset! observed params)
(rx/of {:filename "export.png"
:mtype "image/png"
:uri "blob:export"})))
dwp/force-persist-and-wait (mock/stub (fn [_] (rx/of ::force-persisted)))
dom/trigger-download-uri (mock/stub (fn [& _] nil))}
(fn [done']
(let [completed (fn [_state]
(t/is (= (export-with-name (str (:object-id export)))
(-> @observed :exports first))))]
(ptk/emit! (the/prepare-store (test-state) done' completed)
(de/request-simple-export {:export export})
:the/end)))
done))))
(t/deftest request-multiple-export-sends-normalized-enabled-exports
(t/async done
(let [exports [{:id "enabled-1"
:object-id "enabled-1"
:shape {:id "enabled-1"}
:type :png
:suffix ""
:scale 1
:enabled true
:name ""}]
observed (atom nil)]
(mock/with-mocks {repo/cmd! (mock/stub (fn [_ params]
(reset! observed params)
(rx/of {:id (:id export)})))
ws/get-rcv-stream (mock/stub (fn [_] (rx/empty)))
st/ongoing-tasks (atom #{})}
(fn [done']
(let [completed (fn [_state]
(t/is (= [{:id "enabled-1"
:object-id "enabled-1"
:shape {:id "enabled-1"}
:type :png
:suffix ""
:scale 1
:enabled true
:name "enabled-1"}]
(:exports @observed))))]
(ptk/emit! (the/prepare-store (test-state) done' completed)
(de/request-multiple-export {:exports exports})
:the/end)))
done))))

View File

@ -0,0 +1,49 @@
;; 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 frontend-tests.data.store-test
"Unit tests for app.main.store.
Tests cover:
- format-last-events empty, single, multi, column alignment"
(:require
[app.main.store :as st]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]))
(t/deftest format-last-events-empty
(t/testing "empty events produce empty string"
(t/is (= "" (st/format-last-events [])))))
(t/deftest format-last-events-single
(t/testing "a single event shows (+0ms) and its name"
(let [result (st/format-last-events [{:name ":test/event" :t (js/Date. 1000)}])]
(t/is (str/includes? result "(+0ms)"))
(t/is (str/includes? result ":test/event"))
(t/is (= 1 (count (str/split result "\n")))))))
(t/deftest format-last-events-multi
(t/testing "multiple events show correct deltas and event names"
(let [events [{:name ":event/a" :t (js/Date. 0)}
{:name ":event/b" :t (js/Date. 500)}
{:name ":event/c" :t (js/Date. 2500)}]
lines (str/split (st/format-last-events events) "\n")]
(t/is (= 3 (count lines)))
(t/is (some #(str/includes? % ":event/a") lines))
(t/is (some #(str/includes? % ":event/b") lines))
(t/is (some #(str/includes? % ":event/c") lines))
(t/is (str/includes? (nth lines 0) "(+0ms)"))
(t/is (str/includes? (nth lines 1) "(+500ms)"))
(t/is (str/includes? (nth lines 2) "(+2000ms)")))))
(t/deftest format-last-events-alignment
(t/testing "event names start at the same column across all lines"
(let [events [{:name ":evt-a" :t (js/Date. 0)}
{:name ":evt-b" :t (js/Date. 500)}]
lines (str/split (st/format-last-events events) "\n")
col-a (.indexOf (nth lines 0) ":evt-a")
col-b (.indexOf (nth lines 1) ":evt-b")]
(t/is (pos? col-a))
(t/is (= col-a col-b)))))

View File

@ -7,8 +7,10 @@
[frontend-tests.basic-shapes-test] [frontend-tests.basic-shapes-test]
[frontend-tests.code-gen-style-test] [frontend-tests.code-gen-style-test]
[frontend-tests.copy-as-svg-test] [frontend-tests.copy-as-svg-test]
[frontend-tests.data.exports-assets-test]
[frontend-tests.data.nitrate-test] [frontend-tests.data.nitrate-test]
[frontend-tests.data.repo-test] [frontend-tests.data.repo-test]
[frontend-tests.data.store-test]
[frontend-tests.data.uploads-test] [frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test] [frontend-tests.data.viewer-test]
[frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-colors-test]
@ -47,6 +49,7 @@
[frontend-tests.plugins.value-objects-test] [frontend-tests.plugins.value-objects-test]
[frontend-tests.render-wasm.process-objects-test] [frontend-tests.render-wasm.process-objects-test]
[frontend-tests.svg-fills-test] [frontend-tests.svg-fills-test]
[frontend-tests.text-editor-paste-guard-test]
[frontend-tests.tokens.import-export-test] [frontend-tests.tokens.import-export-test]
[frontend-tests.tokens.logic.token-actions-test] [frontend-tests.tokens.logic.token-actions-test]
[frontend-tests.tokens.logic.token-data-test] [frontend-tests.tokens.logic.token-data-test]
@ -60,7 +63,10 @@
[frontend-tests.util-object-test] [frontend-tests.util-object-test]
[frontend-tests.util-range-tree-test] [frontend-tests.util-range-tree-test]
[frontend-tests.util-simple-math-test] [frontend-tests.util-simple-math-test]
[frontend-tests.util-text-editor-test]
[frontend-tests.util-webapi-test] [frontend-tests.util-webapi-test]
[frontend-tests.util-zip-test]
[frontend-tests.util.dom.dnd-test]
[frontend-tests.worker-snap-test] [frontend-tests.worker-snap-test]
[goog.object :as gobj])) [goog.object :as gobj]))
@ -82,6 +88,8 @@
'frontend-tests.copy-as-svg-test 'frontend-tests.copy-as-svg-test
'frontend-tests.data.nitrate-test 'frontend-tests.data.nitrate-test
'frontend-tests.data.repo-test 'frontend-tests.data.repo-test
'frontend-tests.data.store-test
'frontend-tests.data.exports-assets-test
'frontend-tests.errors-test 'frontend-tests.errors-test
'frontend-tests.main-errors-test 'frontend-tests.main-errors-test
'frontend-tests.data.uploads-test 'frontend-tests.data.uploads-test
@ -132,10 +140,14 @@
'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.ds-controls-numeric-input-test
'frontend-tests.ui.measures-menu-props-test 'frontend-tests.ui.measures-menu-props-test
'frontend-tests.render-wasm.process-objects-test 'frontend-tests.render-wasm.process-objects-test
'frontend-tests.text-editor-paste-guard-test
'frontend-tests.util-object-test 'frontend-tests.util-object-test
'frontend-tests.util-range-tree-test 'frontend-tests.util-range-tree-test
'frontend-tests.util-simple-math-test 'frontend-tests.util-simple-math-test
'frontend-tests.util-text-editor-test
'frontend-tests.util-webapi-test 'frontend-tests.util-webapi-test
'frontend-tests.util.dom.dnd-test
'frontend-tests.util-zip-test
'frontend-tests.worker-snap-test]) 'frontend-tests.worker-snap-test])
(assert (every? find-ns-obj test-namespaces) (assert (every? find-ns-obj test-namespaces)

View File

@ -0,0 +1,62 @@
;; 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
(ns frontend-tests.text-editor-paste-guard-test
"Regression tests for the Cannot read properties of undefined
(reading getData) family of bugs. Each test verifies that the inner
getData call is now guarded by an outer when-let on clipboardData
so that a synthetic event with no clipboardData no longer throws."
(:require
[cljs.test :as t :include-macros true]))
(defn- guarded-get-data-text
"Mirrors the body of the fixed paste handlers in main/ui/forms.cljs and
main/ui/components/forms.cljs."
[event]
(when-let [clipboard-data (.-clipboardData event)]
(.getData clipboard-data "text")))
(defn- guarded-get-data-text-plain
"Mirrors the body of the fixed paste handler in
main/ui/workspace/shapes/text/v3_editor.cljs."
[event]
(when-let [clipboard-data (.-clipboardData event)]
(.getData clipboard-data "text/plain")))
(t/deftest guarded-paste-handlers-do-not-throw-on-missing-clipboardData
(t/testing "event without clipboardData returns nil (no throw)"
(t/is (nil? (guarded-get-data-text #js {})))
(t/is (nil? (guarded-get-data-text-plain #js {}))))
(t/testing "event with explicit nil clipboardData returns nil"
(t/is (nil? (guarded-get-data-text #js {:clipboardData nil})))
(t/is (nil? (guarded-get-data-text-plain #js {:clipboardData nil}))))
(t/testing "event with valid clipboardData returns the text"
(let [text-cb (fn [t] (if (= t "text") "hello" nil))
text-plain-cb (fn [t] (if (= t "text/plain") "hello" nil))
cb #js {:getData (fn [t] (if (= t "text") "hello" nil))}
cbp #js {:getData (fn [t] (if (= t "text/plain") "hello" nil))}]
(t/is (= "hello" (guarded-get-data-text #js {:clipboardData cb})))
(t/is (= "hello" (guarded-get-data-text-plain #js {:clipboardData cbp}))))))
;; Mirrors the fixed styles-fn body in main/ui/workspace/shapes/text/editor.cljs.
;; The fix adds an (and content ...) guard so getText and getData are never
;; called on a nil content object.
(defn- guarded-styles-fn-branch
"Returns the data that styles-fn would use for a given content. When content
is nil, the function falls back to the styles-only branch and returns :fallback
(the real function calls legacy.txt/styles-to-attrs which we don't exercise
here — we only verify the guard itself prevents the getText/getData throws)."
[content]
(if (and content (= (.getText ^js content) ""))
(-> ^js (.getData content)
(.toJS)
(js->clj :keywordize-keys true))
:fallback))
(t/deftest guarded-styles-fn-branch-does-not-throw-on-nil-content
(t/testing "nil content falls back to the styles branch (no throw)"
(t/is (= :fallback (guarded-styles-fn-branch nil)))
(t/is (= :fallback (guarded-styles-fn-branch js/undefined)))))

View File

@ -0,0 +1,23 @@
;; 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
(ns frontend-tests.util.dom.dnd-test
(:require
[app.util.dom.dnd :as dnd]
[cljs.test :as t :include-macros true]))
(t/deftest get-data-returns-nil-when-event-has-no-dataTransfer
(t/testing "event without dataTransfer"
(t/is (nil? (dnd/get-data #js {}))))
(t/testing "event with explicit nil dataTransfer"
(t/is (nil? (dnd/get-data #js {:dataTransfer nil}))))
(t/testing "explicit data-type also returns nil for missing dataTransfer"
(t/is (nil? (dnd/get-data #js {} "penpot/data")))))
(t/deftest get-data-reads-from-dataTransfer
(t/testing "dataTransfer with matching key returns the value (non-decoded type)"
(let [dt #js {:getData (fn [_type] "hello")}]
(t/is (= "hello" (dnd/get-data #js {:dataTransfer dt} "text/plain"))))))

View File

@ -0,0 +1,18 @@
;; 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
(ns frontend-tests.util-text-editor-test
(:require
[app.util.text-editor :as te]
[cljs.test :as t :include-macros true]))
(t/deftest get-editor-block-data-returns-nil-for-nil-block
(t/is (nil? (te/get-editor-block-data nil)))
(t/is (nil? (te/get-editor-block-data js/undefined))))
(t/deftest get-editor-block-type-returns-nil-for-nil-block
(t/is (nil? (te/get-editor-block-type nil)))
(t/is (nil? (te/get-editor-block-type js/undefined))))

View File

@ -0,0 +1,44 @@
;; 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
(ns frontend-tests.util-zip-test
(:require
[app.util.zip :as-alias uz]
[app.worker.import :as worker.import]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[promesa.core :as p]))
(t/deftest read-as-text-nil-entry-raises-typed-error
(t/testing "read-as-text guards against nil entry"
(t/is (thrown-with-msg? js/Error #"nil"
(app.util.zip/read-as-text nil)))))
(t/deftest read-zip-manifest-missing-throws-validation-error
(t/async
done
(t/testing "read-zip-manifest rejects ZIPs without manifest.json"
(mock/with-mocks
{app.util.zip/get-entry (mock/stub (fn [_ _] (p/resolved nil)))}
(fn [done']
(->> (worker.import/read-zip-manifest #js {})
(rx/subs!
(fn [_]
(t/is false "expected validation error to be thrown")
(done'))
(fn [err]
(let [data (ex-data err)]
(t/is (= :invalid-penpot-file (:code data))
"missing manifest.json raises typed :invalid-penpot-file error")
(t/is (string? (:hint data))
"missing manifest.json error carries a :hint")
(t/is (re-find #"manifest\.json" (:hint data))
"missing manifest.json :hint mentions manifest.json")
(t/is (nil? (re-find #"getData" (:hint data)))
"missing manifest.json :hint does not leak the raw TypeError text")
(done'))))))
done))))

View File

@ -1490,6 +1490,9 @@ msgstr "The fonts %s could not be loaded"
msgid "errors.cannot-upload" msgid "errors.cannot-upload"
msgstr "Cannot upload the media file." 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 #: 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" msgid "errors.character-limit-exceeded"
msgstr "Character limit exceeded" msgstr "Character limit exceeded"

View File

@ -1501,6 +1501,9 @@ msgstr "No se han podido cargar las fuentes %s"
msgid "errors.cannot-upload" msgid "errors.cannot-upload"
msgstr "No se puede cargar el archivo multimedia." 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 #: 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" msgid "errors.character-limit-exceeded"
msgstr "Se ha superado el límite de caracteres" msgstr "Se ha superado el límite de caracteres"

View File

@ -272,6 +272,7 @@ The Penpot MCP server can be configured using environment variables.
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | | `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | | `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` |
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |
| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | | `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) |
| `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | | `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) |

View File

@ -127,6 +127,7 @@ export class PenpotMcpServer {
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10); this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10); this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10);
this.tenant = process.env.PENPOT_TENANT ?? "default"; this.tenant = process.env.PENPOT_TENANT ?? "default";
const toolTimeoutSecs = parseInt(process.env.PENPOT_MCP_TOOL_TIMEOUT_S ?? "120", 10);
this.configLoader = new ConfigurationLoader(process.cwd()); this.configLoader = new ConfigurationLoader(process.cwd());
this.apiDocs = new ApiDocs(); this.apiDocs = new ApiDocs();
@ -147,7 +148,7 @@ export class PenpotMcpServer {
this.redisBridge = new RedisBridge(redisUri, this.tenant); this.redisBridge = new RedisBridge(redisUri, this.tenant);
} }
this.pluginBridge = new PluginBridge(this, this.webSocketPort, this.redisBridge); this.pluginBridge = new PluginBridge(this, this.webSocketPort, toolTimeoutSecs, this.redisBridge);
this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host);
} }

View File

@ -20,8 +20,11 @@ interface ClientConnection {
* over these connections. * over these connections.
*/ */
export class PluginBridge { export class PluginBridge {
public static readonly MULTIUSER_CONNECTION_ERROR_MESSAGE = `No Penpot instance connected for user token. Please ensure that Penpot is connected and that the MCP client connection is using the correct token.`;
private readonly logger = createLogger("PluginBridge"); private readonly logger = createLogger("PluginBridge");
private readonly wsServer: WebSocketServer; private readonly wsServer: WebSocketServer;
private readonly connectedClients: Map<WebSocket, ClientConnection> = new Map(); private readonly connectedClients: Map<WebSocket, ClientConnection> = new Map();
private readonly clientsByToken: Map<string, ClientConnection> = new Map(); private readonly clientsByToken: Map<string, ClientConnection> = new Map();
private readonly pendingTasks: Map<string, AbstractPluginTask<any, any>> = new Map(); private readonly pendingTasks: Map<string, AbstractPluginTask<any, any>> = new Map();
@ -37,12 +40,13 @@ export class PluginBridge {
* holding the relevant plugin's WebSocket connection (which may be this same * holding the relevant plugin's WebSocket connection (which may be this same
* instance) via Redis, rather than dispatched directly over a local socket. * instance) via Redis, rather than dispatched directly over a local socket.
* @param taskTimeoutSecs - Timeout, in seconds, for plugin task execution * @param taskTimeoutSecs - Timeout, in seconds, for plugin task execution
* (defaults to {@link DEFAULT_TASK_TIMEOUT_SECS})
*/ */
constructor( constructor(
public readonly mcpServer: PenpotMcpServer, public readonly mcpServer: PenpotMcpServer,
private port: number, private port: number,
private readonly redisBridge?: RedisBridge, private readonly taskTimeoutSecs: number,
private taskTimeoutSecs: number = 30 private readonly redisBridge?: RedisBridge
) { ) {
this.wsServer = new WebSocketServer({ port: port }); this.wsServer = new WebSocketServer({ port: port });
this.setupWebSocketHandlers(); this.setupWebSocketHandlers();
@ -131,9 +135,10 @@ export class PluginBridge {
/** /**
* Removes a client connection and releases all resources associated with it. * Removes a client connection and releases all resources associated with it.
* *
* Clears the per-connection keep-alive interval and removes the connection * Clears the per-connection keep-alive interval and removes the connection from the
* from both the socket-keyed and token-keyed indexes. Safe to call with a * socket-keyed index. The token-keyed index entry (and, in multi-instance mode, the
* socket that is not (or no longer) registered. * token's Redis task subscription) is removed only if it is owned by the given
* connection. Safe to call with a socket that is not (or no longer) registered.
* *
* @param ws - The WebSocket whose connection state should be removed * @param ws - The WebSocket whose connection state should be removed
*/ */
@ -145,12 +150,18 @@ export class PluginBridge {
clearInterval(connection.pingInterval); clearInterval(connection.pingInterval);
this.connectedClients.delete(ws); this.connectedClients.delete(ws);
if (connection.userToken) { if (connection.userToken) {
this.clientsByToken.delete(connection.userToken); // Perform the token-keyed cleanup only if this connection owns the token registration.
// A connection rejected as a duplicate carries the same token but must not remove token associations.
if (this.clientsByToken.get(connection.userToken) !== connection) {
this.logger.debug("Removed connection does not own its token registration; skipping token cleanup");
} else {
this.clientsByToken.delete(connection.userToken);
if (this.redisBridge) { if (this.redisBridge) {
this.redisBridge this.redisBridge
.unsubscribeFromTasks(connection.userToken) .unsubscribeFromTasks(connection.userToken)
.catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel")); .catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel"));
}
} }
} }
} }
@ -189,6 +200,35 @@ export class PluginBridge {
this.logger.info(`Task ${response.id} completed: success=${response.success}`); this.logger.info(`Task ${response.id} completed: success=${response.success}`);
} }
/**
* Rejects a still-pending task with the given error, releasing its correlation state.
*
* Clears the task's timeout (if armed) and removes the task from the pending-task
* index before rejecting its promise. Safe to call for a task that has already been
* settled (e.g. by a response or a timeout), in which case nothing happens.
*
* @param taskId - The ID of the task to reject
* @param error - The error with which to reject the task
* @returns Whether the task was still pending and has been rejected
*/
private rejectPendingTask(taskId: string, error: Error): boolean {
const pendingTask = this.pendingTasks.get(taskId);
if (!pendingTask) {
return false;
}
const timeoutHandle = this.taskTimeouts.get(taskId);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
this.taskTimeouts.delete(taskId);
}
this.pendingTasks.delete(taskId);
pendingTask.rejectWithError(error);
this.logger.info(`Task ${taskId} rejected: ${error.message}`);
return true;
}
/** /**
* Determines the client connection to use for executing a task. * Determines the client connection to use for executing a task.
* *
@ -207,9 +247,7 @@ export class PluginBridge {
const connection = this.clientsByToken.get(sessionContext.userToken); const connection = this.clientsByToken.get(sessionContext.userToken);
if (!connection) { if (!connection) {
throw new Error( throw new Error(PluginBridge.MULTIUSER_CONNECTION_ERROR_MESSAGE);
`No plugin instance connected for user token. Please ensure the plugin is running and connected with the correct token.`
);
} }
return connection; return connection;
@ -257,6 +295,10 @@ export class PluginBridge {
* `resolveWithResult`/`rejectWithError` methods. The same correlation and timeout * `resolveWithResult`/`rejectWithError` methods. The same correlation and timeout
* handling therefore applies regardless of the transport. * handling therefore applies regardless of the transport.
* *
* When routing via Redis, the task is rejected immediately (rather than timing out)
* if the published request reached no instance, i.e. if no instance holds a plugin
* connection for the session's user token, or if publishing fails outright.
*
* @param task - The task to dispatch * @param task - The task to dispatch
* @param useRedis - Whether to route the request via Redis (multi-instance) rather * @param useRedis - Whether to route the request via Redis (multi-instance) rather
* than directly over the local WebSocket connection * than directly over the local WebSocket connection
@ -279,9 +321,17 @@ export class PluginBridge {
// register the task for result correlation, then publish the request via Redis // register the task for result correlation, then publish the request via Redis
this.pendingTasks.set(task.id, task); this.pendingTasks.set(task.id, task);
void redisBridge.sendTaskRequest(userToken, task.toRequest(), (response) => void redisBridge
this.handlePluginTaskResponse(response) .sendTaskRequest(userToken, task.toRequest(), (response) => this.handlePluginTaskResponse(response))
); .then((receiverCount) => {
// fail fast when no instance received the request (no connection with matching user token in any instance)
if (receiverCount === 0) {
this.rejectPendingTask(task.id, new Error(PluginBridge.MULTIUSER_CONNECTION_ERROR_MESSAGE));
}
})
.catch((error) => {
this.rejectPendingTask(task.id, error instanceof Error ? error : new Error(String(error)));
});
// on timeout, release the response-channel subscription, since no response // on timeout, release the response-channel subscription, since no response
// will arrive to trigger its self-unsubscribe. // will arrive to trigger its self-unsubscribe.
@ -300,14 +350,13 @@ export class PluginBridge {
// Set up a timeout to reject the task if no response is received // Set up a timeout to reject the task if no response is received
const timeoutHandle = setTimeout(() => { const timeoutHandle = setTimeout(() => {
const pendingTask = this.pendingTasks.get(task.id); if (
if (pendingTask) { this.rejectPendingTask(
this.pendingTasks.delete(task.id); task.id,
this.taskTimeouts.delete(task.id);
onTimeout?.();
pendingTask.rejectWithError(
new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`) new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`)
); )
) {
onTimeout?.();
} }
}, this.taskTimeoutSecs * 1000); }, this.taskTimeoutSecs * 1000);

View File

@ -91,12 +91,16 @@ export class RedisBridge {
* @param userToken - The user token identifying the target plugin's request channel * @param userToken - The user token identifying the target plugin's request channel
* @param request - The serialized plugin task request, passed through verbatim * @param request - The serialized plugin task request, passed through verbatim
* @param onResponse - Handler invoked with the response when it arrives * @param onResponse - Handler invoked with the response when it arrives
* @returns The number of instances that received the request. A count of 0 means no
* instance is subscribed to the token's request channel (i.e. the plugin is not
* connected anywhere); the request was dropped, no response will ever arrive, and
* the response subscription has already been released.
*/ */
async sendTaskRequest( async sendTaskRequest(
userToken: string, userToken: string,
request: PluginTaskRequest, request: PluginTaskRequest,
onResponse: TaskResponseHandler onResponse: TaskResponseHandler
): Promise<void> { ): Promise<number> {
const responseChannel = this.responseChannel(request.id); const responseChannel = this.responseChannel(request.id);
const requestChannel = this.requestChannel(userToken); const requestChannel = this.requestChannel(userToken);
@ -113,7 +117,19 @@ export class RedisBridge {
await this.subscriber.subscribe(responseChannel); await this.subscriber.subscribe(responseChannel);
// publish only once the response subscription is confirmed // publish only once the response subscription is confirmed
await this.publisher.publish(requestChannel, JSON.stringify(request)); let receiverCount: number;
try {
receiverCount = await this.publisher.publish(requestChannel, JSON.stringify(request));
} catch (error) {
// the request was never delivered, so no response can arrive
await this.unsubscribeFromResponse(request.id);
throw error;
}
if (receiverCount === 0) {
// no subscriber received the request, so no response can arrive
await this.unsubscribeFromResponse(request.id);
}
return receiverCount;
} }
/** /**

478
scripts/ci Executable file
View File

@ -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 <<EOF
Usage: $(basename "$0") [OPTIONS] [MODULES...]
CI orchestration script for Penpot monorepo.
MODULES:
frontend, backend, common, render-wasm, exporter, mcp, plugins, library
Use --all to run all modules.
OPTIONS:
--all Run all modules
--exclude MOD Exclude a module (can be repeated)
--lint Run lint only
--no-lint Skip lint
--test Run tests only
--no-test Skip tests
--fmt Run format check only
--no-fmt Skip format check
--fix Run format fix instead of check
--fail-fast Stop on first failure
--verbose Show command output on failure (default)
--quiet Suppress command output
--dry-run Show what would run without executing
--clean Remove .ci-logs directory
--help Show this help message
EXAMPLES:
$(basename "$0") frontend backend # Run lint, test, fmt on frontend and backend
$(basename "$0") --all --no-test # Run lint and fmt on all modules
$(basename "$0") --lint common # Run only lint on common
$(basename "$0") --fix frontend # Fix formatting in frontend
$(basename "$0") --all --fail-fast --verbose
EOF
exit 0
}
main() {
local exclude_modules=()
local run_all=false
while [[ $# -gt 0 ]]; do
case $1 in
--all)
run_all=true
shift
;;
--exclude)
exclude_modules+=("$2")
shift 2
;;
--lint)
TASKS=("lint")
shift
;;
--no-lint)
TASKS=("${TASKS[@]/lint/}")
shift
;;
--test)
TASKS=("test")
shift
;;
--no-test)
TASKS=("${TASKS[@]/test/}")
shift
;;
--fmt)
TASKS=("fmt")
shift
;;
--no-fmt)
TASKS=("${TASKS[@]/fmt/}")
shift
;;
--fix)
FIX_MODE=true
shift
;;
--fail-fast)
FAIL_FAST=true
shift
;;
--verbose)
VERBOSE=true
shift
;;
--quiet)
VERBOSE=false
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
--clean)
if [[ -d "$LOG_DIR" ]]; then
rm -rf "$LOG_DIR"
echo "Cleaned $LOG_DIR"
else
echo "No logs to clean"
fi
exit 0
;;
--help | -h)
usage
;;
-*)
echo -e "${RED}Error: Unknown option $1${NC}" >&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 "$@"