Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-10-01 19:25:59 +02:00
commit 84c794c5b8
47 changed files with 1553 additions and 823 deletions

View File

@ -1,6 +1,6 @@
# Devenv startup and configuration
Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Parallel instances share infra + Postgres + RustFS; each instance has its own `main` container, Valkey, source checkout, tmux session.
Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Parallel instances share infra + Postgres + RustFS; each instance has its own `main` container, Valkey, source checkout, tmux session. The main database (`penpot`) and the S3 bucket (`penpot`) are shared by all instances on purpose; backend-test databases are isolated per instance (`penpot_test` on ws0, `penpot_test_wsN` on wsN) with per-instance Valkey DB numbers (`6+N`, since runtime backends occupy `0..5`).
## Compose project layout
@ -12,7 +12,8 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
## Source-of-truth files
- `docker/devenv/defaults.env`: ws0 baseline — container/volume names, runtime env, published host ports, tmux defaults. `manage.sh` aborts if unreadable.
- For ws1+, `instance-env-overrides` computes the per-instance overrides (container/volume names, host ports offset `10000·N`, `PENPOT_PUBLIC_URI`, `PENPOT_REDIS_URI`, `PENPOT_BACKEND_WORKER=false`) and `instance-compose` injects them as env vars at compose time — never written to disk, recomputed each call so they can't drift. ws0 uses `defaults.env` as-is.
- For ws1+, `instance-env-overrides` computes the per-instance overrides (container/volume names, host ports offset `10000·N`, `PENPOT_PUBLIC_URI`, `PENPOT_REDIS_URI`, `PENPOT_TEST_DATABASE_URI`, `PENPOT_TEST_REDIS_URI`) and `instance-compose` injects them as env vars at compose time — never written to disk, recomputed each call so they can't drift. ws0 uses `defaults.env` as-is.
- Per-instance test databases are created by `ensure-instance-test-database` (called from `start-instance`): `CREATE DATABASE` guarded by a `pg_database` existence check, so it is idempotent. Needed because `postgresql_init.sql` only runs on first volume creation. The backend applies migrations itself on first use, so an empty database is enough.
- `backend/scripts/_env`: backend-internal only — secret keys, `PENPOT_FLAGS` (with `enable-backend-worker` gated on `PENPOT_BACKEND_WORKER`), `JAVA_OPTS`, `setup_s3_bucket()`. Never duplicates `defaults.env`.
- Compose files use pure `${VAR}` substitution; missing var = compose fails.

View File

@ -16,13 +16,20 @@ development.
# Default connection (penpot db, localhost)
scripts/psql -c "SELECT version();"
# Test database
# Test database (ws0)
scripts/psql --test -c "SELECT * FROM migrations;"
# Isolated test database of another instance (ws1+)
scripts/psql --test --ws 1 -c "SELECT * FROM migrations;"
# Custom host/user/database
scripts/psql --host myhost --user myuser --db mydb
```
`--ws N` only applies together with `--test` (the main database is
shared by all instances): it selects `penpot_test` on `--ws 0` and
`penpot_test_wsN` on `--ws N`. `scripts/db-schema` accepts the same flags.
`scripts/psql` must be invoked from the repo root so the path resolves.
## Native Tool Available (OpenCode V2)

View File

@ -51,4 +51,10 @@
{:permits 4}
:import-binfile/by-profile
{:permits 1 :queue 2}
:profile-plugin-ops/global
{:permits 4}
:profile-plugin-ops/by-profile
{:permits 1 :queue 2}}

View File

@ -221,6 +221,8 @@
;; ═══════════════════════════════════════════════
#{:main/update-profile
:main/update-profile-props
:main/add-profile-plugin
:main/remove-profile-plugin
:main/update-profile-photo
:main/update-profile-password
:main/update-profile-notifications

View File

@ -165,6 +165,9 @@
[:binfile-import-max-text-total-size {:optional true} ::sm/int]
[:binfile-import-max-zip-entries {:optional true} ::sm/int]
;; Max serialized size of profile props in bytes (default 2 MiB)
[:profile-props-max-size {:optional true} ::sm/int]
[:login-lockout-max-attempts {:optional true} ::sm/int]
[:login-lockout-window {:optional true} ::ct/duration]

View File

@ -8,6 +8,7 @@
"A penpot notification service for file cooperative edition."
(:require
[app.binfile.common :as bfc]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
@ -18,6 +19,7 @@
[app.http.session :as session]
[app.metrics :as mtx]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.util.websocket :as ws]
@ -42,13 +44,13 @@
(defn repl-get-connections-for-file
[file-id]
(->> (vals @state)
(filter #(= file-id (-> % deref ::file-subscription :file-id)))
(filter #(= file-id (-> % ::ws/state deref ::file-subscription :file-id)))
(map ::ws/id)))
(defn repl-get-connections-for-team
[team-id]
(->> (vals @state)
(filter #(= team-id (-> % deref ::team-subscription :team-id)))
(filter #(= team-id (-> % ::ws/state deref ::team-subscription :team-id)))
(map ::ws/id)))
(defn repl-close-connection
@ -60,15 +62,17 @@
(defn repl-get-connection-info
[id]
(when-let [wsp (get @state id)]
{:id id
:created-at (::created-at wsp)
:profile-id (::profile-id wsp)
:session-id (::session-id wsp)
:user-agent (::ws/user-agent wsp)
:ip-addr (::ws/remote-addr wsp)
:last-activity-at (::ws/last-activity-at wsp)
:subscribed-file (-> wsp ::file-subscription :file-id)
:subscribed-team (-> wsp ::team-subscription :team-id)}))
(let [subs (some-> wsp ::ws/state deref)]
{:id id
:created-at (::created-at wsp)
:profile-id (::profile-id wsp)
:session-id (::session-id wsp)
:user-agent (::ws/user-agent wsp)
:ip-addr (::ws/remote-addr wsp)
:last-activity-at (::ws/last-activity-at wsp)
:subscribed-file (-> subs ::file-subscription :file-id)
:subscribed-team (-> subs ::team-subscription :team-id)
:subscribed-org (-> subs ::team-subscription :organization-id)})))
(defn repl-print-connection-info
[id]
@ -133,18 +137,39 @@
(mbus/purge! msgbus [channel])
(mbus/pub! msgbus :topic topic :message msg))))
(defn- get-team-organization-id
"Returns the id of the organization that owns `team-id`, or nil when
the team has no organization or nitrate cannot be reached."
[cfg team-id]
(try
(-> (nitrate/call cfg :get-team-organization {:team-id team-id})
(dm/get-in [:organization :id]))
(catch Throwable cause
(l/warn :hint "unable to resolve team organization"
:team-id team-id
:cause cause)
nil)))
(defmethod handle-message :subscribe-team
[cfg {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
(teams/check-read-permissions! cfg profile-id team-id)
(let [prev-subs (get @state ::team-subscription)
channel (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
(let [prev-subs (get @state ::team-subscription)
organization-id (get-team-organization-id cfg team-id)
;; Resolved server-side so a client only hears its readable team's org
topics (cond-> [team-id]
(some? organization-id)
(conj organization-id))
channel (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
(sp/pipe channel output-ch false)
(mbus/sub! (::mbus/msgbus cfg) :topic team-id :chan channel)
(mbus/sub! (::mbus/msgbus cfg) :topics topics :chan channel)
(let [subs {:team-id team-id :channel channel :topic team-id}]
(let [subs {:team-id team-id
:organization-id organization-id
:channel channel
:topic team-id}]
(swap! state assoc ::team-subscription subs))
;; Close previous subscription if exists

View File

@ -379,11 +379,12 @@
::setup/props (ig/ref ::setup/props)}
::http.ws/routes
{::db/pool (ig/ref ::db/pool)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::setup/props (ig/ref ::setup/props)
::session/manager (ig/ref ::session/manager)}
{::db/pool (ig/ref ::db/pool)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::setup/props (ig/ref ::setup/props)
::session/manager (ig/ref ::session/manager)
:app.nitrate/client (ig/ref :app.nitrate/client)}
:app.http.assets/routes
{::http.assets/path (cf/get :assets-path)

View File

@ -11,6 +11,7 @@
[app.common.types.plugins :as ctp]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
@ -33,6 +34,8 @@
(sv/defmethod ::add-profile-plugin
{::doc/added "2.18"
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
[:profile-plugin-ops/global]]
::sm/params schema:add-profile-plugin
::sm/result ctp/schema:registry-entry
::db/transaction true}
@ -42,14 +45,22 @@
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
plugin-id (:plugin-id plugin)
plugins (-> plugins
(update :ids #(vec (distinct (conj % plugin-id))))
(assoc-in [:data plugin-id] plugin))]
(db/update! conn :profile
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
{:id profile-id}
{::db/return-keys false})
plugin))
exists? (contains? (set (:ids plugins)) plugin-id)]
(when (and (not exists?) (>= (count (:ids plugins)) ctp/max-plugins))
(ex/raise :type :validation
:code :too-many-plugins
:hint "plugin registry exceeds maximum size"))
(let [plugins (-> plugins
(update :ids #(vec (distinct (conj % plugin-id))))
(assoc-in [:data plugin-id] plugin))
props (-> (:props profile)
(assoc :plugins plugins)
(profile/check-props-size))]
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
plugin)))
(def ^:private
schema:remove-profile-plugin
@ -58,18 +69,28 @@
(sv/defmethod ::remove-profile-plugin
{::doc/added "2.18"
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
[:profile-plugin-ops/global]]
::sm/params schema:remove-profile-plugin
::sm/result :nil
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}]
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
plugin-id-str (str plugin-id)
plugins (-> plugins
(update :ids #(vec (remove (partial = plugin-id-str) %)))
(update :data dissoc plugin-id-str))]
(db/update! conn :profile
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
{:id profile-id}
{::db/return-keys false})
nil))
plugin-id-str (str plugin-id)]
(if-not (or (some #(= % plugin-id-str) (:ids plugins))
(contains? (:data plugins) plugin-id-str))
;; Nothing to remove: no write
nil
(let [plugins (-> plugins
(update :ids #(vec (remove (partial = plugin-id-str) %)))
(update :data dissoc plugin-id-str))
;; Raises when the remaining props still exceed the size limit
props (-> (:props profile)
(assoc :plugins plugins)
(profile/check-props-size))]
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))))

View File

@ -12,6 +12,7 @@
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.transit :as t]
[app.common.types.plugins :as ctp]
[app.common.uuid :as uuid]
[app.config :as cf]
@ -36,6 +37,7 @@
[cuerdas.core :as str]))
(declare check-profile-existence!)
(declare check-props-size)
(declare decode-row)
(declare filter-props)
(declare get-profile)
@ -283,7 +285,8 @@
props
(-> (get profile :props)
(assoc :notifications notifications))]
(assoc :notifications notifications)
(check-props-size))]
(db/update! conn :profile
{:props (db/tjson props)}
@ -474,18 +477,46 @@
[:map {:title "update-profile-props"}
[:props schema:props-writeable]])
(def default-props-max-size
"Default total serialized size limit (in bytes) for profile props.
Overridable with the :profile-props-max-size config entry."
(* 1024 1024 2)) ;; 2 MiB
(defn- props-size
"Returns the serialized size in UTF-8 bytes of the props map."
[props]
(if props
(alength ^bytes (t/encode props {:type :json-verbose}))
0))
(defn check-props-size
"Raises :props-too-large when props exceed the total size limit.
Returns props unchanged so it can be threaded into the write.
Used by the user-facing props writers; system writers (OIDC login,
management subscription) write fixed-key props and skip it."
[props]
(let [limit (cf/get :profile-props-max-size default-props-max-size)
size (props-size props)]
(when (> size limit)
(ex/raise :type :validation
:code :props-too-large
:hint "profile props exceed maximum size"))
props))
(defn update-profile-props
[{:keys [::db/conn] :as cfg} profile-id props]
(let [profile (get-profile conn profile-id ::db/for-update true)
props (reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile)
(apply dissoc props system-managed-props))]
props (->> (apply dissoc props system-managed-props)
(reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile))
(check-props-size))]
(db/update! conn :profile
{:props (db/tjson props)}
@ -658,8 +689,41 @@
[props]
(into {} (filter (fn [[k _]] (simple-ident? k))) props))
(defn- truncate-string
"Cuts s to at most n chars without splitting a surrogate pair."
[^String s n]
(if (> (count s) n)
(let [n (if (Character/isHighSurrogate (.charAt s (dec n))) (dec n) n)]
(subs s 0 n))
s))
(defn- clamp-plugin-entry
[entry]
(reduce-kv (fn [entry k n]
(let [v (get entry k)]
(if (and (string? v) (> (count v) n))
(assoc entry k (truncate-string v n))
entry)))
entry
ctp/registry-entry-max-lengths))
(defn clamp-plugins-registry
"Fits a plugin registry into the registry schema caps: keeps the
first `ctp/max-plugins` plugins with data and truncates the bounded
strings. Applied on read, so a stored registry over the caps cannot
keep the profile over the props size limit."
[{:keys [ids data] :as plugins}]
(let [ids (->> ids
(filter #(contains? data %))
(distinct)
(take ctp/max-plugins)
(vec))
data (update-vals (select-keys data ids) clamp-plugin-entry)]
(assoc plugins :ids ids :data data)))
(defn decode-row
[{:keys [props] :as row}]
(cond-> row
(db/pgobject? props "jsonb")
(assoc :props (db/decode-transit-pgobject props))))
(assoc :props (-> (db/decode-transit-pgobject props)
(d/update-when :plugins #(cond-> % (map? %) (clamp-plugins-registry)))))))

View File

@ -6,16 +6,14 @@
(ns app.rpc.notifications
(:require
[app.common.uuid :as uuid]
[app.msgbus :as mbus]))
(defn notify-team-change
[cfg team notification]
(let [msgbus (::mbus/msgbus cfg)]
(let [msgbus (::mbus/msgbus cfg)
team-id (:id team)]
(mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:topic team-id
:message {:type :team-organization-change
:team team
:notification notification})))
@ -37,7 +35,7 @@
[cfg organization-id organization-name teams deleted-teams]
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
:topic uuid/zero
:topic organization-id
:message {:type :organization-deleted
:organization-id organization-id
:organization-name organization-name
@ -48,6 +46,6 @@
[cfg organization-id]
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
:topic uuid/zero
:topic organization-id
:message {:type :organization-change-sso
:organization-id organization-id})))

View File

@ -60,6 +60,13 @@
(def ^:dynamic *system* nil)
(def ^:dynamic *pool* nil)
;; Fallback values used when no PENPOT_TEST_* env vars are set (e.g. CI sets
;; them explicitly, see .github/workflows/tests-backend.yml). Inside the
;; devenv each wsN container receives per-instance values via
;; manage.sh (PENPOT_TEST_DATABASE_URI=postgresql://postgres/penpot_test_wsN,
;; PENPOT_TEST_REDIS_URI=redis://valkey/<6+N>), so parallel test runs on
;; different workspaces never share a database. cf/read-config picks the env
;; vars up through the "penpot-test" prefix.
(def default
{:database-uri "postgresql://postgres/penpot_test"
:redis-uri "redis://valkey/1"

View File

@ -10,6 +10,7 @@
[app.db :as db]
[app.http.websocket :as ws]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
@ -68,6 +69,50 @@
(t/testing "permission check passes for authorized user"
(t/is (nil? (teams/check-read-permissions! cfg (:id profile1) (:id team)))))))
(defn- subscribed-topics
"Runs :subscribe-team for `profile-id` on `team-id` with `nitrate-call`
standing in for nitrate; returns the topics and the stored subscription."
[profile-id team-id nitrate-call]
(let [state (atom {})
output-ch (sp/chan :buf (sp/dropping-buffer 64))
wsp (make-wsp profile-id state output-ch)
calls (atom [])]
(with-redefs [nitrate/call nitrate-call
mbus/sub! (fn [_ & {:keys [topics]}]
(swap! calls conj topics))]
((get-method ws/handle-message :subscribe-team)
th/*system* wsp {:team-id team-id}))
(some-> @state ::ws/team-subscription :channel sp/close!)
{:topics @calls
:subscription (::ws/team-subscription @state)}))
(t/deftest subscribe-team-subscribes-to-team-organization
(let [profile (th/create-profile* 1 {:is-active true})
team-id (:id (th/create-team* 1 {:profile-id (:id profile)}))
org-id (uuid/next)]
(t/testing "adds the organization topic when the team has one"
(let [{:keys [topics subscription]}
(subscribed-topics (:id profile) team-id
(fn [_ method params]
(when (= :get-team-organization method)
{:id (:team-id params)
:organization {:id org-id}})))]
(t/is (= [[team-id org-id]] topics))
(t/is (= org-id (:organization-id subscription)))))
(t/testing "subscribes only to the team when it has no organization"
(let [{:keys [topics subscription]}
(subscribed-topics (:id profile) team-id (constantly nil))]
(t/is (= [[team-id]] topics))
(t/is (nil? (:organization-id subscription)))))
(t/testing "subscribes to the team when nitrate fails"
(let [{:keys [topics]}
(subscribed-topics (:id profile) team-id
(fn [& _] (throw (ex-info "nitrate down" {}))))]
(t/is (= [[team-id]] topics))))))
(t/deftest pointer-update-validates-file-id
(let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile)

View File

@ -316,7 +316,7 @@
:organization organization}))]
(t/is (th/success? out))
(t/is (= 1 (count @calls)))
(t/is (= uuid/zero (-> @calls first :topic)))
(t/is (= team-id (-> @calls first :topic)))
(let [msg (-> @calls first :message)]
(t/is (= :team-organization-change (:type msg)))
(t/is (= nil (:notification msg)))
@ -340,7 +340,7 @@
:organization {:name organization-name}}))]
(t/is (th/success? out))
(t/is (= 1 (count @calls)))
(t/is (= uuid/zero (-> @calls first :topic)))
(t/is (= team-id (-> @calls first :topic)))
(let [msg (-> @calls first :message)]
(t/is (= :team-organization-change (:type msg)))
(t/is (= "dashboard.team-no-longer-belong-organization" (:notification msg)))
@ -548,7 +548,7 @@
;; --- Verify: exactly one organization-deleted event is published on the message bus ---
(t/is (:called? @mbus-mock))
(let [msg (apply hash-map (rest (:call-args @mbus-mock)))]
(t/is (= uuid/zero (:topic msg)))
(t/is (= organization-id (:topic msg)))
(t/is (= :organization-deleted (:type (:message msg))))
(t/is (= organization-id (:organization-id (:message msg))))
(t/is (= organization-name (:organization-name (:message msg))))
@ -557,6 +557,24 @@
(t/is (= #{(:id empty-team)}
(set (:deleted-teams (:message msg))))))))))
(t/deftest notify-organization-change-sso-publishes-event
(let [organization-id (uuid/random)
calls (atom [])
out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}]
(swap! calls conj {:topic topic
:message message}))]
(th/management-command! {::th/type :notify-organization-sso-change
::rpc/profile-id (uuid/random)
:organization-id organization-id
:updated-props true
:announce-activation false}))]
(t/is (th/success? out))
(t/is (= 1 (count @calls)))
(t/is (= organization-id (-> @calls first :topic)))
(let [msg (-> @calls first :message)]
(t/is (= :organization-change-sso (:type msg)))
(t/is (= organization-id (:organization-id msg))))))
(t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-organization-events
;; --- Deferred owned-organizations: nil during setup, filled before RPC ---
(let [owned-organizations-ref (atom nil)]
@ -664,7 +682,7 @@
;; --- Verify: one organization-deleted event per organization, all on correct topic ---
(t/is (= 2 (count msgs)))
(t/is (every? #(= uuid/zero (:topic %))
(t/is (every? #(contains? #{organization-1-id organization-2-id} (:topic %))
(->> (:call-args-list @mbus-mock)
(map #(apply hash-map (rest %))))))
(t/is (= #{:organization-deleted} (set (map :type msgs))))

View File

@ -6,7 +6,11 @@
(ns backend-tests.rpc-plugins-test
(:require
[app.common.schema :as sm]
[app.common.types.plugins :as ctp]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[backend-tests.helpers :as th]
@ -145,6 +149,140 @@
(t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name])))
(t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name]))))))
(t/deftest add-profile-plugin-rejects-oversized-code
;; The merged props must not exceed :profile-props-max-size
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))
data {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin}]
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest add-profile-plugin-rejects-oversized-code-path
;; :code holds a manifest path, not content: overlong values are
;; rejected by the entry schema before the props size check runs
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 501 "x")))
data {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest remove-profile-plugin-allowed-on-oversized-profile
;; Removal shrinks props, so it passes even under a tight limit
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))]
;; Seed an oversized registry while the limit is high
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100000})]
(let [out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)))))
;; Removal under a tighter limit still passes: the seeded registry
;; is oversized against it, but the remaining props fit
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 300})]
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/uuid plugin-id-1)})]
(t/is (nil? (:error out)))))))
(t/deftest add-profile-plugin-rejects-51st-plugin
;; The registry holds at most 50 plugins; the 51st (new id) must fail
(let [profile (th/create-profile* 1)]
;; Seed 50 plugins
(doseq [i (range 50)]
(let [plugin (assoc valid-plugin
:plugin-id (str (uuid/next))
:name (str "Plugin " i))
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)) (str "seed plugin " i " should install"))))
;; The 51st must fail with a specific error
(let [extra (assoc valid-plugin
:plugin-id (str (uuid/next))
:name "One Too Many")
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin extra})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :too-many-plugins)))
;; And nothing extra was persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
(t/deftest add-profile-plugin-updates-existing-at-limit
;; Re-adding an existing id at the limit is an update, not a new entry
(let [profile (th/create-profile* 1)
ids (mapv (fn [_] (str (uuid/next))) (range 50))]
(doseq [[i pid] (map-indexed vector ids)]
(th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin (assoc valid-plugin :plugin-id pid :name (str "Plugin " i))}))
(let [out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin (assoc valid-plugin :plugin-id (first ids) :name "Renamed")})]
(t/is (nil? (:error out)))
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids]))))
(t/is (= "Renamed" (get-in props [:props :plugins :data (first ids) :name])))))))
(t/deftest add-profile-plugin-full-registry-reports-too-many-before-size
;; A full registry plus oversized content reports the count guard,
;; which runs before the size check
(let [profile (th/create-profile* 1)]
;; Seed 50 plugins under a generous limit
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 1000000})]
(doseq [i (range 50)]
(let [plugin (assoc valid-plugin
:plugin-id (str (uuid/next))
:name (str "Plugin " i))
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)) (str "seed plugin " i " should install")))))
;; Tight limit + 51st small plugin: count wins over size
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [extra (assoc valid-plugin
:plugin-id (str (uuid/next))
:name "One Too Many")
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin extra})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :too-many-plugins))))
;; And nothing extra was persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
(t/deftest remove-profile-plugin-noop-on-oversized-profile-without-plugins
;; Removing an absent id changes nothing: no write, no size failure,
;; and no :plugins key is manufactured
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/next)})]
(t/is (nil? (:error out)))))
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (nil? (get-in props [:props :plugins])))
(t/is (= big (get props :props))))))
(t/deftest update-profile-props-rejects-plugins
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-props
@ -160,3 +298,86 @@
props (profile/decode-row saved)]
(t/is (nil? (get-in props [:props :plugins]))
":plugins must not be writable via update-profile-props"))))
;; --- Stored registries over the caps
(defn- legacy-entry
[plugin-id & {:as attrs}]
(merge valid-plugin {:plugin-id plugin-id :code "plugin.js"} attrs))
(defn- legacy-registry
[entries]
{:ids (mapv :plugin-id entries)
:data (into {} (map (juxt :plugin-id identity)) entries)})
(defn- stored-props
"Props as stored in the database, without the read-time clamping."
[profile-id]
(db/decode-transit-pgobject (:props (th/db-get :profile {:id profile-id}))))
(t/deftest get-profile-clamps-legacy-registry
(let [profile (th/create-profile* 1)
big (apply str (repeat 10000 "x"))
;; "😀" is two chars, so char 500 is a high surrogate
emoji (str (apply str (repeat 499 "n")) "😀")
entries (into [(legacy-entry plugin-id-1
:name emoji
:description big
:host big
:code big
:icon big)]
(map #(legacy-entry (str "extra-" %)))
(range 60))
stored (-> (legacy-registry entries)
(update :ids conj "dangling"))]
(th/db-update! :profile {:props (db/tjson {:plugins stored :renderer :wasm})}
{:id (:id profile)})
(let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)})
props (get-in out [:result :props])
plugins (:plugins props)
entry (get-in plugins [:data plugin-id-1])]
(t/is (nil? (:error out)))
(t/is (= :wasm (:renderer props)) "other props are kept")
(t/is (= (mapv :plugin-id (take ctp/max-plugins entries)) (:ids plugins))
"keeps the first plugins in registry order, drops ids without data")
(t/is (= (set (:ids plugins)) (set (keys (:data plugins)))))
(t/is (= (apply str (repeat 499 "n")) (:name entry)) "does not split a surrogate pair")
(doseq [k [:description :host :code :icon]]
(t/is (= (get ctp/registry-entry-max-lengths k) (count (get entry k)))
(str k " truncated to its cap")))
(t/is (sm/validate ctp/schema:plugin-registry plugins)))
(t/is (= stored (:plugins (stored-props (:id profile))))
"reading does not write")))
(t/deftest get-profile-keeps-valid-registry
(let [profile (th/create-profile* 1)
stored (legacy-registry [(legacy-entry plugin-id-1)])]
(th/db-update! :profile {:props (db/tjson {:plugins stored})} {:id (:id profile)})
(let [out (th/command! {::th/type :get-profile ::rpc/profile-id (:id profile)})]
(t/is (= stored (get-in out [:result :props :plugins]))))))
(t/deftest oversized-legacy-profile-recovers-on-write
;; A stored registry over the caps pushes props past the size limit;
;; the clamped read lets writes through and saves the clamped registry
(let [profile (th/create-profile* 1)
code (apply str (repeat 3000 "c"))
entries [(legacy-entry plugin-id-1 :code code)
(legacy-entry plugin-id-2 :code code)]]
(th/db-update! :profile {:props (db/tjson {:plugins (legacy-registry entries)})}
{:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 5000})]
(t/is (thrown? Exception (profile/check-props-size (stored-props (:id profile))))
"the stored props exceed the limit")
(let [out (th/command! {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:workspace-visited true}})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/uuid plugin-id-1)})]
(t/is (nil? (:error out)))))
(let [props (stored-props (:id profile))]
(t/is (true? (:workspace-visited props)))
(t/is (= [plugin-id-2] (get-in props [:plugins :ids])))
(t/is (= 500 (count (get-in props [:plugins :data plugin-id-2 :code])))
"the write saved the clamped registry"))))

View File

@ -1549,6 +1549,111 @@
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest update-profile-props-rejects-oversized-props
;; The merged props must not exceed :profile-props-max-size
(let [profile (th/create-profile* 1)]
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-props-enforces-hard-limit-on-oversized-profile
;; An already-oversized profile can only write back under the limit:
;; shrinking below it passes, staying above it fails
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
;; Seed an already-oversized profile directly in DB (bypasses RPC validation)
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
;; Shrinking below the limit passes
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob "small"}}}
out (th/command! data)]
(t/is (nil? (:error out))))
;; Staying above the limit fails
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 300 "x"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest check-props-size-measures-bytes-not-chars
;; The limit is in UTF-8 bytes: multibyte content that fits in chars
;; but exceeds the byte limit must be rejected
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 200})]
;; 70 ASCII chars (~87 bytes serialized) passes and returns props unchanged
(let [props {:blob (apply str (repeat 70 "x"))}]
(t/is (= props (profile/check-props-size props))))
;; 70 CJK chars (~227 bytes serialized, still 70 chars) raises
(try
(profile/check-props-size {:blob (apply str (repeat 70 "日"))})
(t/is false "should have thrown")
(catch clojure.lang.ExceptionInfo e
(t/is (= :validation (:type (ex-data e))))
(t/is (= :props-too-large (:code (ex-data e))))))))
(t/deftest update-profile-props-rejects-steady-size-on-oversized-profile
;; Same size (not smaller) on an oversized profile still exceeds
;; the limit, so it fails
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "y"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-notifications-rejects-growth-on-oversized-profile
;; The notifications write path goes through the same size check:
;; growing an oversized profile fails
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-notifications
::rpc/profile-id (:id profile)
:dashboard-comments :all
:email-comments :all
:email-invites :all}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-notifications-rejects-steady-on-oversized-profile
;; Same-size notifications write on an oversized profile still exceeds
;; the limit, so it fails
(let [profile (th/create-profile* 1)
notifications {:dashboard-comments :all
:email-comments :all
:email-invites :all}
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}
:notifications notifications}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data (merge {::th/type :update-profile-notifications
::rpc/profile-id (:id profile)}
notifications)
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest prepare-register-profile-password-too-short
(let [data {::th/type :prepare-register-profile
:email "user@example.com"

View File

@ -41,21 +41,34 @@
"Schema for plugin permissions - a set of valid permission strings."
[:set {:gen/max 11} (into [:enum] (sort valid-permissions))])
(def max-plugins
"Maximum number of plugins a profile can hold."
50)
(def registry-entry-max-lengths
"Maximum length (in chars) of the bounded registry entry strings.
`:code` and `:icon` hold manifest paths, not content."
{:name 500
:description 4096
:host 500
:code 500
:icon 500})
(def schema:registry-entry
[:map
[:plugin-id :string]
[:version {:optional true} :int]
[:name :string]
[:description {:optional true} :string]
[:host :string]
[:code :string]
[:icon {:optional true} :string]
[:name [:string {:max (:name registry-entry-max-lengths)}]]
[:description {:optional true} [:string {:max (:description registry-entry-max-lengths)}]]
[:host [:string {:max (:host registry-entry-max-lengths)}]]
[:code [:string {:max (:code registry-entry-max-lengths)}]]
[:icon {:optional true} [:string {:max (:icon registry-entry-max-lengths)}]]
[:permissions schema:permissions]])
(def schema:plugin-registry
[:map
[:ids [:vector :string]]
[:ids [:vector {:max max-plugins} :string]]
[:data
[:map-of {:gen/max 5}
[:map-of {:gen/max 5 :max max-plugins}
:string
schema:registry-entry]]])

View File

@ -209,14 +209,6 @@
[node]
(= "root" (:type node)))
(defn rtl-content?
"True when the content has paragraphs and all of them are `\"rtl\"`; picks the
growth anchor of auto-width text. Mixed, \"none\" and empty content are ltr."
[content]
(boolean
(when-let [paragraphs (node-seq is-paragraph-node? content)]
(every? #(= "rtl" (:text-direction %)) paragraphs))))
(defn is-node?
[node]
(or ^boolean (is-text-node? node)

View File

@ -84,6 +84,7 @@
[common-tests.types.objects-map-test]
[common-tests.types.organization-test]
[common-tests.types.path-data-test]
[common-tests.types.plugins-test]
[common-tests.types.shape-decode-encode-test]
[common-tests.types.shape-interactions-test]
[common-tests.types.shape-layout-test]
@ -167,6 +168,7 @@
'common-tests.types.objects-map-test
'common-tests.types.organization-test
'common-tests.types.path-data-test
'common-tests.types.plugins-test
'common-tests.types.shape-decode-encode-test
'common-tests.types.shape-interactions-test
'common-tests.types.shape-layout-test

View File

@ -0,0 +1,64 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.plugins-test
(:require
[app.common.schema :as sm]
[app.common.types.plugins :as ctp]
[clojure.test :as t]))
(def ^:private valid-entry
{:plugin-id "plugin-1"
:name "Test Plugin"
:description "A test plugin"
:host "https://example.com"
:code "(function() {})()"
:permissions #{"content:read"}})
(t/deftest registry-entry-accepts-valid-plugin
(t/is (true? (sm/validate ctp/schema:registry-entry valid-entry))))
(t/deftest registry-entry-rejects-oversized-code
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry
:code (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-name
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :name (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-host
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :host (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-description
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :description (apply str (repeat 4097 "x")))))))
(t/deftest registry-entry-rejects-oversized-icon
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :icon (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-accepts-values-at-max
(t/is (true? (sm/validate ctp/schema:registry-entry
(assoc valid-entry
:name (apply str (repeat 500 "x"))
:host (apply str (repeat 500 "x"))
:description (apply str (repeat 4096 "x"))
:icon (apply str (repeat 500 "x"))
:code (apply str (repeat 500 "x")))))))
(defn- make-registry
[n]
(let [ids (mapv #(str "plugin-" %) (range n))]
{:ids ids
:data (into {} (map (fn [id] [id (assoc valid-entry :plugin-id id)]) ids))}))
(t/deftest plugin-registry-accepts-fifty-plugins
(t/is (true? (sm/validate ctp/schema:plugin-registry (make-registry 50)))))
(t/deftest plugin-registry-rejects-more-than-fifty-plugins
(t/is (false? (sm/validate ctp/schema:plugin-registry (make-registry 51)))))

View File

@ -81,6 +81,11 @@ services:
- PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD}
- PENPOT_DATABASE_MAX_POOL_SIZE=${PENPOT_DATABASE_MAX_POOL_SIZE}
- PENPOT_REDIS_URI=${PENPOT_REDIS_URI}
# Per-instance backend-test config. Values come from
# instance-env-overrides in manage.sh; the main database above stays
# shared by all instances on purpose.
- PENPOT_TEST_DATABASE_URI=${PENPOT_TEST_DATABASE_URI}
- PENPOT_TEST_REDIS_URI=${PENPOT_TEST_REDIS_URI}
- PENPOT_OBJECTS_STORAGE_BACKEND=${PENPOT_OBJECTS_STORAGE_BACKEND}
- PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}
- PENPOT_OBJECTS_STORAGE_S3_BUCKET=${PENPOT_OBJECTS_STORAGE_S3_BUCKET}

View File

@ -274,6 +274,7 @@ hay
hubo
importada
iniciarse
instalarla
invitaciones
lectores
letras

View File

@ -50,18 +50,28 @@
(ptk/reify ::initialize
ptk/WatchEvent
(watch [_ state stream]
(let [stopper (rx/filter (ptk/type? ::finalize) stream)
profile-id (:profile-id state)]
(let [stopper (rx/filter (ptk/type? ::finalize) stream)
profile-id (:profile-id state)
organization-id (dm/get-in state [:teams team-id :organization :id])
initmsg {:type :subscribe-team :team-id team-id}]
(->> (rx/merge
(rx/of (fetch-projects team-id)
(df/fetch-fonts team-id))
(df/fetch-fonts team-id)
(dws/send initmsg))
;; On reconnect, send again the subscription message
(->> stream
(rx/filter (ptk/type? ::dws/opened))
(rx/map #(dws/send initmsg)))
(->> stream
(rx/filter (ptk/type? ::dws/message))
(rx/map deref)
(rx/filter (fn [{:keys [topic] :as msg}]
(or (= topic uuid/zero)
(= topic profile-id))))
(= topic profile-id)
(= topic team-id)
(when (some? organization-id)
(= topic organization-id)))))
(rx/map process-message)))
(rx/take-until stopper))))))

View File

@ -52,6 +52,7 @@
(watch [_ state stream]
(let [stopper (rx/filter (ptk/type? ::finalize) stream)
profile-id (:profile-id state)
organization-id (dm/get-in state [:teams team-id :organization :id])
initmsg [{:type :subscribe-file
:file-id file-id
@ -75,7 +76,9 @@
(or (= topic uuid/zero)
(= topic profile-id)
(= topic team-id)
(= topic file-id))))
(= topic file-id)
(when (some? organization-id)
(= topic organization-id)))))
(rx/map process-message))
;; On reconnect, send again the subscription messages

View File

@ -16,7 +16,6 @@
[app.common.geom.point :as gpt]
[app.common.render-wasm.api.select :as wselect]
[app.common.types.modifiers :as ctm]
[app.common.types.text :as ctt]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.modifiers :as dwm]
@ -65,16 +64,12 @@
([shape]
(resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id points selrect grow-type] :as shape} content]
([{:keys [id points selrect] :as shape} content]
(when-let [new-size (get-wasm-text-new-size shape content)]
(let [width-scale (/ (:width new-size) (:width selrect))
height-scale (/ (:height new-size) (:height selrect))
resize-v (gpt/point width-scale height-scale)
;; Rtl text grows leftward, so anchor it on the top-right.
origin (if (and (= :auto-width grow-type)
(ctt/rtl-content? content))
(second points)
(first points))]
origin (first points)]
{id
{:modifiers
(ctm/resize-modifiers

View File

@ -719,6 +719,15 @@
:level :error
:timeout 3000})))
;; Rejected profile write: notify and keep the app running
(= code :props-too-large)
(let [message (tr "errors.profile-props-too-large")]
(st/async-emit!
(ntf/show {:content message
:type :toast
:level :error
:timeout 5000})))
(= code :snapshot-already-locked)
(let [message (tr "errors.version-already-locked")]
(st/async-emit!

View File

@ -79,6 +79,7 @@
.modal-msg {
@include use-typography("body-large");
overflow-wrap: anywhere;
color: var(--color-foreground-secondary);
}

View File

@ -806,7 +806,7 @@
:submenu-item true
:disabled (not can-open?))
:on-key-down on-key-down}
[:span {:class (stl/css :item-name)} name]
[:span {:class (stl/css :item-name :plugin-name) :title name} name]
(when-not can-open?
[:span {:title (tr "workspace.plugins.error.need-editor")}
[:> icon* {:icon-id i/help

View File

@ -9,6 +9,7 @@
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/mixins.scss" as *;
.base-menu {
position: absolute;
@ -70,6 +71,7 @@
}
&.plugins {
max-width: $sz-364;
max-height: calc(100vh - $sz-200);
overflow: hidden auto;
}
@ -120,6 +122,12 @@
grid-area: name;
}
.plugin-name {
@include text-ellipsis;
min-width: 0;
}
.item-indicator {
--menu-indicator-color: var(--color-foreground-secondary);

View File

@ -56,7 +56,7 @@
icon))
(mf/defc plugin-entry*
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin]}]
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin remove-disabled]}]
(let [{:keys [plugin-id host icon name description permissions]} manifest
plugins-permissions-peek (deref refs/plugins-permissions-peek)
@ -86,8 +86,8 @@
(icon-url host icon)
(avatars/generate {:name name}))}]]
[:div {:class (stl/css :plugin-description)}
[:div {:class (stl/css :plugin-title)} name]
[:div {:class (stl/css :plugin-summary)} (d/nilv description "")]]
[:div {:class (stl/css :plugin-title) :title name} name]
[:div {:class (stl/css :plugin-summary) :title description} (d/nilv description "")]]
[:> button* {:class (stl/css :open-button)
@ -100,6 +100,7 @@
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.plugins.remove-plugin")
:on-click handle-delete-click
:disabled remove-disabled
:icon i/delete}]]))
(mf/defc plugin-management-dialog
@ -126,6 +127,10 @@
fetching-manifest?
(mf/use-state false)
;; Ids with a persist in flight; their remove button is disabled
in-flight*
(mf/use-state #{})
on-url-change
(mf/use-fn
(fn [value]
@ -174,13 +179,35 @@
(mf/deps plugins-state)
(fn [plugin-index]
(let [plugins-list (preg/plugins-list)
plugin (nth plugins-list plugin-index)]
(st/emit! (ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(dp/close-plugin! plugin)
(preg/remove-plugin! plugin)
(reset! plugins-state* (preg/plugins-list)))))]
plugin (nth plugins-list plugin-index)
plugin-name (:name plugin)
;; Truncated so long names fit the confirm dialog
plugin-name (if (> (count plugin-name) 60)
(str (subs plugin-name 0 60) "…")
plugin-name)]
(modal/show!
{:type :confirm
:title (tr "workspace.plugins.remove-confirmation.title")
:message (tr "workspace.plugins.remove-confirmation.message" plugin-name)
:accept-label (tr "workspace.plugins.remove-plugin")
:on-accept (fn [_]
(st/emit! (ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(dp/close-plugin! plugin)
(preg/remove-plugin! plugin)
(modal/show! :plugin-management {}))
:on-cancel (fn [_]
(modal/show! :plugin-management {}))}))))]
(mf/with-effect []
(let [listener (preg/subscribe-in-flight! #(reset! in-flight* %))]
(partial preg/unsubscribe-in-flight! listener)))
;; Re-reads the list on every registry change, including rollbacks
(mf/with-effect []
(let [listener (preg/subscribe-registry! #(reset! plugins-state* (preg/plugins-list)))]
(partial preg/unsubscribe-registry! listener)))
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-management)}
@ -236,6 +263,7 @@
:index idx
:manifest manifest
:user-can-edit user-can-edit?
:remove-disabled (contains? @in-flight* (:plugin-id manifest))
:on-open-plugin on-open-plugin
:on-remove-plugin on-remove-plugin}])]])]]]))

View File

@ -5,6 +5,7 @@
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/mixins.scss" as *;
.modal-overlay {
@extend %modal-overlay-base;
@ -15,6 +16,7 @@
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-columns: minmax(0, 1fr);
max-height: initial;
&.plugin-permissions {
@ -160,17 +162,21 @@
flex-direction: column;
gap: deprecated.$s-8;
width: 100%;
min-width: 0;
}
.plugin-title {
@include deprecated.body-medium-typography;
@include text-ellipsis;
color: var(--color-foreground-primary);
}
.plugin-summary {
@include deprecated.body-small-typography;
@include two-line-text-ellipsis;
overflow-wrap: anywhere;
color: var(--color-foreground-secondary);
}

View File

@ -25,18 +25,11 @@
;; finalize-only), so measure the live WASM text for the growing axes:
;; width grows on auto-width, height on auto-width/auto-height.
grow-type (:grow-type shape)
{live-x :x live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape))
{live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape))
sr-width (if (= grow-type :auto-width) live-width (:width selrect))
sr-height (if (= grow-type :fixed) (:height selrect) live-height)
;; Rtl auto-width text is anchored on its right edge, so use the origin
;; wasm reports. A zero measurement means it has no layout yet.
sr-x (if (and (= grow-type :auto-width)
(some? live-x)
(pos? live-width))
live-x
(:x selrect))]
sr-height (if (= grow-type :fixed) (:height selrect) live-height)]
[:rect.main.viewport-selrect
{:x sr-x
{:x (:x selrect)
:y (:y selrect)
:width sr-width
:height sr-height

View File

@ -214,7 +214,7 @@
(font-family-from-font-id (:font-id font))) fallback-fonts)
[{:keys [x y width height]} transform]
(let [{text-x :x :keys [width height]} (wasm.api/get-text-dimensions shape-id)
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect)
vbox (mf/deref refs/vbox)
[selrect transform] (dsh/get-selrect selrect-transform shape)
@ -231,20 +231,13 @@
overlay-width (if (= (:grow-type shape) :auto-width)
(+ max-width viewport-width)
max-width)
;; `on-pointer-down` feeds offsets within this element to wasm as
;; paragraph-local coords, so this edge must sit on the text's.
x (if (and (= (:grow-type shape) :auto-width)
(some? text-x)
(pos? width))
text-x
(:x selrect))
valign (-> shape :content :vertical-align)
y (:y selrect)
y (case valign
"bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2))
y)]
[(assoc selrect :x x :y y :width overlay-width :height max-height) transform])
[(assoc selrect :y y :width overlay-width :height max-height) transform])
on-composition-start
(mf/use-fn

View File

@ -112,6 +112,20 @@
manifest
(.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest))))))
(defn subscribe-registry!
"Subscribes f, called with no arguments on every registry change.
Returns f."
[f]
(add-watch registry f (fn [_ _ old new]
(when-not (identical? old new)
(f))))
f)
(defn unsubscribe-registry!
[f]
(remove-watch registry f)
nil)
(defn load-from-store
[]
(reset! registry (get-in @st/state [:profile :props :plugins] {})))
@ -122,36 +136,128 @@
(declare remove-plugin!)
;; Plugin ids with a persist in flight; install/remove calls on them are skipped
(defonce ^:private in-flight (atom #{}))
(defonce ^:private in-flight-listeners (atom #{}))
(defn subscribe-in-flight!
"Subscribes f, called with the in-flight id set on every change.
Calls f immediately with the current set. Returns f."
[f]
(swap! in-flight-listeners conj f)
(f @in-flight)
f)
(defn unsubscribe-in-flight!
[f]
(swap! in-flight-listeners disj f)
nil)
(defn- notify-in-flight!
[]
(let [ids @in-flight]
(doseq [f @in-flight-listeners]
(f ids))))
(defn- track!
[plugin-id]
(swap! in-flight conj plugin-id)
(notify-in-flight!))
(defn- release!
[plugin-id]
(swap! in-flight disj plugin-id)
(notify-in-flight!))
(defn- validation-error?
[err]
(= :validation (:type (ex-data err))))
(defn- drop-local!
[{:keys [plugin-id]}]
(swap! registry #(-> %
(update :ids (fn [ids] (vec (remove (partial = plugin-id) ids))))
(update :data dissoc plugin-id))))
(defn- insert-at
[ids idx id]
(let [v (vec ids)
idx (max 0 (min idx (count v)))]
(vec (concat (subvec v 0 idx) [id] (subvec v idx)))))
(defn- restore-local!
"Puts the stored plugin back into the registry at position idx."
[{:keys [plugin-id] :as plugin} idx]
(swap! registry #(-> %
(update :ids (fn [ids]
(insert-at (remove (partial = plugin-id) ids)
idx
plugin-id)))
(assoc-in [:data plugin-id] plugin))))
(defn install-plugin!
[plugin]
(letfn [(update-ids [ids]
(conj
(->> ids (remove #(= % (:plugin-id plugin))))
(:plugin-id plugin)))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data assoc (:plugin-id plugin) plugin)))
(->> (rp/cmd! :add-profile-plugin {:plugin plugin})
(rx/subs! identity
(fn [err]
(remove-plugin! plugin)
(.error js/console "Failed to install plugin:" err))))))
(let [plugin-id (:plugin-id plugin)
previous (get-plugin plugin-id)
prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
(when-not (contains? @in-flight plugin-id)
(track! plugin-id)
(letfn [(update-ids [ids]
(conj
(->> ids (remove #(= % (:plugin-id plugin))))
(:plugin-id plugin)))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data assoc (:plugin-id plugin) plugin)))
(->> (rp/cmd! :add-profile-plugin {:plugin plugin})
(rx/subs! (fn [_]
(release! plugin-id))
(fn [err]
(release! plugin-id)
;; Restore the previous version in place, else drop it
(if previous
(restore-local! previous prev-idx)
(drop-local! plugin))
;; Other failures may have reached the server: undo it
;; once by re-saving the previous version or removing
;; the new entry, without further rollback.
(when-not (validation-error? err)
(->> (if previous
(rp/cmd! :add-profile-plugin {:plugin previous})
(rp/cmd! :remove-profile-plugin {:plugin-id plugin-id}))
(rx/subs! (fn [_] nil)
(fn [err2]
(.error js/console "Rollback failed:" err2)))))
(.error js/console "Failed to install plugin:" err))))))))
(defn remove-plugin!
[{:keys [plugin-id]}]
(let [plugin (get-plugin plugin-id)]
(letfn [(update-ids [ids]
(->> ids
(remove #(= % plugin-id))))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data dissoc plugin-id)))
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
(rx/subs! identity
(fn [err]
(when plugin
(install-plugin! plugin))
(.error js/console "Failed to remove plugin:" err)))))))
(let [stored (get-plugin plugin-id)
prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
(when-not (contains? @in-flight plugin-id)
(track! plugin-id)
(letfn [(update-ids [ids]
(->> ids
(remove #(= % plugin-id))))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data dissoc plugin-id)))
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
(rx/subs! (fn [_]
(release! plugin-id))
(fn [err]
(release! plugin-id)
(when stored
;; Restore in place; validation errors keep it server-side
(restore-local! stored prev-idx)
;; Other failures: re-save it once, without further rollback
(when-not (validation-error? err)
(->> (rp/cmd! :add-profile-plugin {:plugin stored})
(rx/subs! (fn [_] nil)
(fn [err2]
(.error js/console "Rollback install failed:" err2))))))
(.error js/console "Failed to remove plugin:" err))))))))
(defn check-permission
[plugin-id permission]

View File

@ -9,10 +9,13 @@
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.websocket :as dws]
[app.main.repo :as rp]
[app.main.router :as rt]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
@ -82,3 +85,48 @@
(fn []
(done')))))
done))))
(defn- sent-messages
"Runs the watch of `event` against `stream` with `dws/send` stubbed
and resolves to the messages it sends over the websocket."
[event state stream]
(let [sent (atom [])]
(-> (mock/with-mocks*
{dws/send (mock/stub (fn [msg] (swap! sent conj msg) msg))}
(await (async/observe (ptk/watch event state stream))))
(.then (fn [_] @sent)))))
(t/deftest ^:async dashboard-initialize-subscribes-to-team
(let [team-id (uuid/next)
state {:profile-id (uuid/next)
:teams {team-id {:id team-id :organization {:id (uuid/next)}}}}
sent (await (sent-messages (dd/initialize team-id) state (rx/empty)))]
(t/is (= [{:type :subscribe-team :team-id team-id}] sent))))
(t/deftest ^:async dashboard-initialize-resubscribes-on-reconnect
(let [team-id (uuid/next)
state {:profile-id (uuid/next)
:teams {team-id {:id team-id}}}
stream (rx/of (ptk/data-event ::dws/opened {})
(ptk/data-event ::dws/opened {}))
sent (await (sent-messages (dd/initialize team-id) state stream))]
(t/is (= 3 (count sent)))
(t/is (every? #(= {:type :subscribe-team :team-id team-id} %) sent))))
(t/deftest ^:async dashboard-initialize-accepts-team-organization-messages
(let [team-id (uuid/next)
org-id (uuid/next)
state {:profile-id (uuid/next)
:teams {team-id {:id team-id :organization {:id org-id}}}}
message (fn [topic]
(ptk/data-event ::dws/message
{:type :organization-change-sso
:topic topic
:organization-id org-id}))
stream (rx/of (message org-id) (message (uuid/next)))
processed (atom [])]
(await (mock/with-mocks*
{dws/send (mock/stub identity)}
(await (async/observe (ptk/watch (dd/initialize team-id) state stream)
:on-next #(swap! processed conj (ptk/type %))))))
(t/is (= 1 (count (filter #{::dcm/handle-organization-change-sso} @processed))))))

View File

@ -1,131 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.data.wasm-text-test
"Growth anchor of auto-grow wasm text shapes (see `resize-wasm-text-modifiers`).
Tests stub the wasm bridge and assert on the shape the modifiers produce."
(:require
[app.common.geom.shapes :as gsh]
[app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts]
[app.main.data.workspace.wasm-text :as dwwt]
[cljs.test :as t :include-macros true]))
;; ---------------------------------------------------------------------------
;; Helpers
;; ---------------------------------------------------------------------------
(defn- make-content
"Text content whose paragraphs carry the given `:text-direction` values."
[& directions]
{:type "root"
:children [{:type "paragraph-set"
:children (vec (for [direction directions]
{:type "paragraph"
:text-direction direction
:children [{:text "hello"}]}))}]})
(defn- make-text-shape
[& {:keys [x y width height grow-type rotation]
:or {x 100 y 50 width 60 height 20 grow-type :auto-width}}]
(let [shape (-> (cts/setup-shape {:type :text
:x x
:y y
:width width
:height height})
(assoc :grow-type grow-type))]
(if (some? rotation)
(gsh/transform-shape
shape
(ctm/rotation-modifiers shape (gsh/shape->center shape) rotation))
shape)))
(defn- resized
"Shape that results from `resize-wasm-text-modifiers` when the renderer
measures `new-size`."
[shape content new-size]
;; The stub needs the real fn's arities: a variadic `fn` has no `arity$2`
;; dispatch, so the 2-arity call site blows up.
(with-redefs [dwwt/get-wasm-text-new-size (fn ([_] new-size) ([_ _] new-size))]
(let [modifiers (dwwt/resize-wasm-text-modifiers shape content)]
(gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers])))))
(defn- close? [a b]
(< (abs (- a b)) 0.01))
;; ---------------------------------------------------------------------------
;; Growth anchor
;; ---------------------------------------------------------------------------
(t/deftest rtl-auto-width-keeps-its-right-edge-when-growing
(t/testing "an rtl auto-width shape extends leftward: the right and top edges
stay put and x moves left"
(let [shape (make-text-shape)
content (make-content "rtl")
before (:selrect shape)
after (:selrect (resized shape content {:width 120 :height 20}))]
(t/is (close? (+ (:x after) (:width after))
(+ (:x before) (:width before)))
"right edge preserved")
(t/is (close? (:y after) (:y before)) "top edge preserved")
(t/is (close? (:x after) 40) "x moved left by the growth")
(t/is (close? (:width after) 120)))))
(t/deftest rtl-auto-width-keeps-its-right-edge-when-shrinking
(t/testing "deleting text shrinks the box from the left, right edge preserved"
(let [shape (make-text-shape)
content (make-content "rtl")
before (:selrect shape)
after (:selrect (resized shape content {:width 30 :height 20}))]
(t/is (close? (+ (:x after) (:width after))
(+ (:x before) (:width before)))
"right edge preserved")
(t/is (close? (:x after) 130) "x moved right as the box narrowed"))))
(t/deftest ltr-auto-width-keeps-its-left-edge
(t/testing "ltr content is untouched: the left edge stays anchored"
(let [shape (make-text-shape)
content (make-content "ltr")
after (:selrect (resized shape content {:width 120 :height 20}))]
(t/is (close? (:x after) 100) "left edge preserved")
(t/is (close? (:width after) 120)))))
(t/deftest mixed-direction-auto-width-keeps-its-left-edge
(t/testing "a box with one rtl and one ltr paragraph keeps the previous
left-anchored growth"
(let [shape (make-text-shape)
content (make-content "rtl" "ltr")
after (:selrect (resized shape content {:width 120 :height 20}))]
(t/is (close? (:x after) 100) "left edge preserved"))))
(t/deftest rtl-auto-height-keeps-its-left-edge
(t/testing "auto-height only ever changes height, so the anchor is irrelevant
and x must not move"
(let [shape (make-text-shape :grow-type :auto-height)
content (make-content "rtl")
after (:selrect (resized shape content {:width 60 :height 80}))]
(t/is (close? (:x after) 100) "x preserved")
(t/is (close? (:width after) 60) "width preserved")
(t/is (close? (:height after) 80) "height grew"))))
(t/deftest rotated-rtl-auto-width-grows-along-its-own-axis
(t/testing "a rotated rtl shape keeps its own top-right corner, not the
axis-aligned one"
(let [shape (make-text-shape :rotation 30)
content (make-content "rtl")
before (:points shape)
after (:points (resized shape content {:width 120 :height 20}))]
;; points are [top-left top-right bottom-right bottom-left]
(t/is (close? (:x (second after)) (:x (second before)))
"top-right x preserved")
(t/is (close? (:y (second after)) (:y (second before)))
"top-right y preserved"))))
(t/deftest no-modifiers-when-the-renderer-has-no-size
(t/testing "a nil measurement (shape absent from wasm state) skips the resize"
(let [shape (make-text-shape)]
(with-redefs [dwwt/get-wasm-text-new-size (fn ([_] nil) ([_ _] nil))]
(t/is (nil? (dwwt/resize-wasm-text-modifiers shape (make-content "rtl"))))))))

View File

@ -581,53 +581,3 @@
(t/testing "a non-root content (e.g. paragraph) is left alone"
(let [node {:type "paragraph" :children []}]
(t/is (= node (dwt/ensure-valid-text-content node))))))
;; ---------------------------------------------------------------------------
;; txt/rtl-content?
;; ---------------------------------------------------------------------------
(defn- make-content
"Text content whose paragraphs carry the given `:text-direction` values; a
`nil` entry leaves the attribute out entirely."
[& directions]
{:type "root"
:children [{:type "paragraph-set"
:children (vec (for [direction directions]
(cond-> {:type "paragraph"
:children [{:text "hello"}]}
(some? direction)
(assoc :text-direction direction))))}]})
(t/deftest rtl-content-single-rtl-paragraph
(t/testing "a lone rtl paragraph makes the content rtl"
(t/is (true? (txt/rtl-content? (make-content "rtl"))))))
(t/deftest rtl-content-every-paragraph-rtl
(t/testing "several paragraphs, all rtl"
(t/is (true? (txt/rtl-content? (make-content "rtl" "rtl" "rtl"))))))
(t/deftest rtl-content-mixed-directions
(t/testing "a mix of rtl and ltr is not rtl"
(t/is (false? (txt/rtl-content? (make-content "rtl" "ltr"))))))
(t/deftest rtl-content-missing-direction-on-one-paragraph
(t/testing "a paragraph without :text-direction defaults to ltr, so the
content is not rtl"
(t/is (false? (txt/rtl-content? (make-content "rtl" nil))))))
(t/deftest rtl-content-all-ltr
(t/testing "all-ltr content is not rtl"
(t/is (false? (txt/rtl-content? (make-content "ltr" "ltr"))))))
(t/deftest rtl-content-none-direction-is-ltr
(t/testing "\"none\" (the sidebar's un-toggled value) is ltr, matching what
translate-text-direction sends to the renderer"
(t/is (false? (txt/rtl-content? (make-content "none"))))))
(t/deftest rtl-content-without-paragraphs
(t/testing "a root with no paragraph nodes is not rtl"
(t/is (false? (txt/rtl-content? {:type "root" :children []})))))
(t/deftest rtl-content-nil
(t/testing "nil content is not rtl and does not throw"
(t/is (false? (txt/rtl-content? nil)))))

View File

@ -0,0 +1,322 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.plugins.register-test
(:require
[app.main.repo :as rp]
[app.plugins.register :as preg]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]))
(defn- record-cmd-mock
"Mock rp/cmd! that records calls in mock/rpc-calls and answers
with (response-fn cmd params)."
[response-fn]
(mock/stub
(fn [cmd params]
(swap! mock/rpc-calls conj {:cmd cmd :params params})
(response-fn cmd params))))
(defn- cmds
[]
(mapv :cmd @mock/rpc-calls))
;; --- install-plugin! ---
(t/deftest install-success-releases-id
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
(fn [done']
(let [plugin {:plugin-id "reg-install-ok"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin] (cmds)))
(t/is (= plugin (preg/get-plugin "reg-install-ok")))
;; id released: a second install issues a second RPC
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest install-while-in-flight-issues-no-second-rpc
(t/async done
(let [subjects (atom [])]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _]
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb)))}
(fn [done']
(let [plugin {:plugin-id "reg-install-dedupe"}]
(preg/install-plugin! plugin)
(preg/install-plugin! plugin)
(t/is (= 1 (count @mock/rpc-calls)) "second install while in flight is skipped")
;; complete the pending call; the id is released afterwards
(rx/push! (first @subjects) {:ok true})
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done))))
(t/deftest install-validation-error-cleans-local-only
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _] (rx/throw (ex-info "rejected" {:type :validation :code :props-too-large}))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-validation"}]
(preg/install-plugin! plugin)
(t/is (= 1 (count @mock/rpc-calls)) "no rollback write after validation rejection")
(t/is (nil? (preg/get-plugin "reg-install-validation")))
;; id released: the next install retries the RPC
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest install-validation-error-restores-previous-version
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "rejected" {:type :validation}))
(rx/of {:ok true}))))}
(fn [done']
(let [v1 {:plugin-id "reg-install-prev" :name "v1"}
v2 {:plugin-id "reg-install-prev" :name "v2"}]
(preg/install-plugin! v1)
(preg/install-plugin! v2)
(t/is (= 2 (count @mock/rpc-calls)))
(t/is (= v1 (preg/get-plugin "reg-install-prev"))
"the server-kept version is restored, not dropped")
(done')))
done)))
(t/deftest install-validation-error-keeps-original-position
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "rejected" {:type :validation}))
(rx/of {:ok true}))))}
(fn [done']
(let [own #{"reg-pos-a" "reg-pos-b" "reg-pos-c"}
v1 {:plugin-id "reg-pos-b" :name "v1"}
v2 {:plugin-id "reg-pos-b" :name "v2"}]
(preg/install-plugin! {:plugin-id "reg-pos-a"})
(preg/install-plugin! v1)
(preg/install-plugin! {:plugin-id "reg-pos-c"})
(preg/install-plugin! v2)
;; installs prepend, so newest-first; the rejected update
;; must preserve order and restore v1
(t/is (= ["reg-pos-c" "reg-pos-b" "reg-pos-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(t/is (= v1 (preg/get-plugin "reg-pos-b")))
(done')))
done)))
(t/deftest install-persistent-failure-terminates
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _] (rx/throw (ex-info "boom" {:type :other}))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-hang"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
"one-shot rollback: no further calls")
(t/is (nil? (preg/get-plugin "reg-install-hang")))
(done')))
done)))
(t/deftest install-non-validation-error-rolls-back-via-rpc
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/throw (ex-info "boom" {:type :other}))
(rx/of nil))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-rollback"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
(t/is (nil? (preg/get-plugin "reg-install-rollback")))
(done')))
done)))
(t/deftest install-non-validation-error-on-update-resaves-previous
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "busy" {:type :concurrency-limit}))
(rx/of {:ok true}))))}
(fn [done']
(let [own #{"reg-upd-a" "reg-upd-b" "reg-upd-c"}
v1 {:plugin-id "reg-upd-b" :name "v1"}
v2 {:plugin-id "reg-upd-b" :name "v2"}]
(preg/install-plugin! {:plugin-id "reg-upd-a"})
(preg/install-plugin! v1)
(preg/install-plugin! {:plugin-id "reg-upd-c"})
(reset! mock/rpc-calls [])
(preg/install-plugin! v2)
(t/is (= [:add-profile-plugin :add-profile-plugin] (cmds))
"the compensating write re-saves v1, never removes it")
(t/is (= v1 (get-in (second @mock/rpc-calls) [:params :plugin])))
(t/is (= v1 (preg/get-plugin "reg-upd-b")))
(t/is (= ["reg-upd-c" "reg-upd-b" "reg-upd-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(done')))
done)))
;; --- remove-plugin! ---
(t/deftest remove-success-releases-id
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-ok"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
(t/is (nil? (preg/get-plugin "reg-remove-ok")))
;; id released: a second remove issues another RPC
(preg/remove-plugin! plugin)
(t/is (= 3 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest remove-while-in-flight-issues-no-second-rpc
(t/async done
(let [subjects (atom [])]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-dedupe"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)) "second remove while in flight is skipped")
;; complete the pending call; the id is released afterwards
(rx/push! (first @subjects) nil)
(preg/remove-plugin! plugin)
(t/is (= 3 (count @mock/rpc-calls)))
(done')))
done))))
(t/deftest remove-validation-error-restores-local-only
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "rejected" {:type :validation})))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-validation"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
"no reinstall write after validation rejection")
(t/is (= plugin (preg/get-plugin "reg-remove-validation"))
"local entry is restored")
(done')))
done)))
(t/deftest remove-non-validation-error-reinstalls-via-rpc
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :remove-profile-plugin)
(rx/throw (ex-info "boom" {:type :other}))
(rx/of {:ok true}))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-rollback"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds)))
(t/is (= plugin (preg/get-plugin "reg-remove-rollback")))
(done')))
done)))
(t/deftest remove-persistent-failure-terminates
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "boom" {:type :other})))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-hang"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds))
"one-shot rollback: no further calls")
(t/is (= plugin (preg/get-plugin "reg-remove-hang")))
(done')))
done)))
(t/deftest remove-validation-error-keeps-original-position
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "rejected" {:type :validation})))))}
(fn [done']
(let [own #{"reg-idx-a" "reg-idx-b" "reg-idx-c"}
plugin {:plugin-id "reg-idx-b"}]
(preg/install-plugin! {:plugin-id "reg-idx-a"})
(preg/install-plugin! plugin)
(preg/install-plugin! {:plugin-id "reg-idx-c"})
(preg/remove-plugin! plugin)
;; installs prepend, so the order is newest-first;
;; the failed removal must preserve it exactly
(t/is (= ["reg-idx-c" "reg-idx-b" "reg-idx-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(done')))
done)))
;; --- subscribe-registry! ---
(t/deftest registry-subscriber-sees-rollback
(t/async done
(let [subjects (atom [])
seen (atom [])
listener (fn [] (swap! seen conj (some? (preg/get-plugin "reg-watch"))))]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _]
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb)))}
(fn [done']
(preg/subscribe-registry! listener)
(preg/install-plugin! {:plugin-id "reg-watch"})
(rx/error! (first @subjects) (ex-info "rejected" {:type :validation}))
(t/is (= [true false] @seen)
"notified on the optimistic add and on the rollback")
(preg/unsubscribe-registry! listener)
(preg/install-plugin! {:plugin-id "reg-watch"})
(t/is (= [true false] @seen) "no calls after unsubscribing")
(done'))
done))))

View File

@ -21,7 +21,6 @@
[frontend-tests.data.svg-upload-test]
[frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test]
[frontend-tests.data.wasm-text-test]
[frontend-tests.data.workspace-colors-test]
[frontend-tests.data.workspace-comments-test]
[frontend-tests.data.workspace-context-menu-test]
@ -72,6 +71,7 @@
[frontend-tests.plugins.page-active-validation-test]
[frontend-tests.plugins.page-test]
[frontend-tests.plugins.parser-test]
[frontend-tests.plugins.register-test]
[frontend-tests.plugins.shape-bugfixes-test]
[frontend-tests.plugins.text-test]
[frontend-tests.plugins.tokens-test]
@ -156,7 +156,6 @@
'frontend-tests.data.svg-upload-test
'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test
'frontend-tests.data.wasm-text-test
'frontend-tests.data.workspace-colors-test
'frontend-tests.data.workspace-comments-test
'frontend-tests.data.workspace-context-menu-test
@ -207,6 +206,7 @@
'frontend-tests.plugins.page-active-validation-test
'frontend-tests.plugins.page-test
'frontend-tests.plugins.parser-test
'frontend-tests.plugins.register-test
'frontend-tests.plugins.shape-bugfixes-test
'frontend-tests.plugins.text-test
'frontend-tests.plugins.tokens-test

View File

@ -1438,11 +1438,11 @@ msgstr "Attention"
msgid "ds.component-subtitle"
msgstr "Components to update:"
#: src/app/main/ui/workspace/plugins.cljs:379, src/app/main/ui/workspace/plugins.cljs:442
#: src/app/main/ui/workspace/plugins.cljs:407, src/app/main/ui/workspace/plugins.cljs:470
msgid "ds.confirm-allow"
msgstr "Allow"
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:373, src/app/main/ui/workspace/plugins.cljs:436
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:401, src/app/main/ui/workspace/plugins.cljs:464
msgid "ds.confirm-cancel"
msgstr "Cancel"
@ -1524,7 +1524,7 @@ msgstr ""
"Clipboard access denied. Please allow clipboard permissions in your browser "
"to paste content"
#: src/app/main/errors.cljs:773
#: src/app/main/errors.cljs:782
msgid "errors.comment-error"
msgstr "There was an error with the comment"
@ -1532,21 +1532,21 @@ msgstr "There was an error with the comment"
msgid "errors.connection-error"
msgstr "Cannot reach the server. Check your connection and try again."
#: src/app/main/errors.cljs:842
#: src/app/main/errors.cljs:851
msgid "errors.deprecated"
msgstr ""
"Sorry! This is an old file that uses a deprecated type of Penpot assets and "
"you can't open it."
#: src/app/main/errors.cljs:845
#: src/app/main/errors.cljs:854
msgid "errors.deprecated.contact.after"
msgstr "so we can help you."
#: src/app/main/errors.cljs:843
#: src/app/main/errors.cljs:852
msgid "errors.deprecated.contact.before"
msgstr "Although Penpot no longer support this type of Assets, you can"
#: src/app/main/errors.cljs:844
#: src/app/main/errors.cljs:853
msgid "errors.deprecated.contact.text"
msgstr "contact us"
@ -1582,13 +1582,13 @@ msgstr "The email «%s» has many permanent bounce reports."
msgid "errors.email-spam-or-permanent-bounces"
msgstr "The email «%s» has been reported as spam or permanently bounce."
#: src/app/main/errors.cljs:819
#: src/app/main/errors.cljs:828
msgid "errors.feature-mismatch"
msgstr ""
"Looks like you are opening a file that has the feature '%s' enabled but the "
"current penpot version does not supports it or has it disabled."
#: src/app/main/errors.cljs:823, src/app/main/errors.cljs:837
#: src/app/main/errors.cljs:832, src/app/main/errors.cljs:846
msgid "errors.feature-not-supported"
msgstr "Feature '%s' is not supported."
@ -1608,7 +1608,7 @@ msgstr "Empty field"
msgid "errors.field-not-all-whitespace"
msgstr "The name must contain some character other than space."
#: src/app/main/errors.cljs:815
#: src/app/main/errors.cljs:824
msgid "errors.file-feature-mismatch"
msgstr ""
"It seems that there is a mismatch between the enabled features and the "
@ -1621,11 +1621,11 @@ msgstr ""
"The font family name can only contain letters, numbers, spaces, hyphens, "
"underscores, and dots."
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:158, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:131, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
msgid "errors.generic"
msgstr "Something wrong has happened."
#: src/app/main/errors.cljs:752
#: src/app/main/errors.cljs:761
msgid "errors.internal-worker-error"
msgstr "Something wrong has happened with the web worker."
@ -1675,7 +1675,7 @@ msgstr "This invite might be canceled or may be expired."
msgid "errors.ldap-disabled"
msgstr "LDAP authentication is disabled."
#: src/app/main/errors.cljs:831, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
#: src/app/main/errors.cljs:840, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
msgid "errors.max-quota-reached"
msgstr "You have reached the '%s' quota. Contact support."
@ -1701,7 +1701,7 @@ msgstr "Seems that this is not a valid image."
msgid "errors.member-is-muted"
msgstr "The profile you inviting has emails muted (spam reports or high bounces)."
#: src/app/main/errors.cljs:805
#: src/app/main/errors.cljs:814
msgid "errors.migration-in-progress"
msgstr "Migration in progress"
@ -1745,6 +1745,12 @@ msgstr "The profile is blocked"
msgid "errors.profile-is-muted"
msgstr "Your profile has emails muted (spam reports or high bounces)."
#: src/app/main/errors.cljs:724
msgid "errors.profile-props-too-large"
msgstr ""
"Your profile settings are too large to save. Remove some plugins and try "
"again."
#: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96
msgid "errors.registration-disabled"
msgstr "The registration is currently disabled."
@ -1759,11 +1765,11 @@ msgstr ""
msgid "errors.save-retrying"
msgstr "Connection lost. Retrying to save your changes."
#: src/app/main/errors.cljs:764
#: src/app/main/errors.cljs:773
msgid "errors.svg-parser.invalid-svg"
msgstr "SVG is invalid or malformed"
#: src/app/main/errors.cljs:810
#: src/app/main/errors.cljs:819
msgid "errors.team-feature-mismatch"
msgstr "Detected incompatible feature '%s'"
@ -1936,7 +1942,7 @@ msgstr "An unexpected error occurred."
msgid "errors.unexpected-token"
msgstr "Unknown token"
#: src/app/main/errors.cljs:723
#: src/app/main/errors.cljs:732
msgid "errors.version-already-locked"
msgstr "This version is already locked"
@ -1944,7 +1950,7 @@ msgstr "This version is already locked"
msgid "errors.version-locked"
msgstr "This version is locked and cannot be deleted by others"
#: src/app/main/errors.cljs:827
#: src/app/main/errors.cljs:836
msgid "errors.version-not-supported"
msgstr "File has an incompatible version number"
@ -8099,16 +8105,16 @@ msgstr "Snap nodes (%s)"
msgid "workspace.plugins.button-open"
msgstr "Open"
#: src/app/main/ui/workspace/plugins.cljs:215
#: src/app/main/ui/workspace/plugins.cljs:242
#, markdown
msgid "workspace.plugins.discover"
msgstr "Discover [more plugins](%s)"
#: src/app/main/ui/workspace/plugins.cljs:222
#: src/app/main/ui/workspace/plugins.cljs:249
msgid "workspace.plugins.empty-plugins"
msgstr "No plugins installed yet"
#: src/app/main/ui/workspace/plugins.cljs:209
#: src/app/main/ui/workspace/plugins.cljs:236
msgid "workspace.plugins.error.manifest"
msgstr "The plugin manifest is incorrect."
@ -8120,15 +8126,15 @@ msgstr "You need to be an editor to use this plugin"
msgid "workspace.plugins.error.unreachable"
msgstr "The plugin URL could not be reached."
#: src/app/main/ui/workspace/plugins.cljs:205
#: src/app/main/ui/workspace/plugins.cljs:232
msgid "workspace.plugins.error.url"
msgstr "The plugin doesn't exist or the URL is not correct."
#: src/app/main/ui/workspace/plugins.cljs:201
#: src/app/main/ui/workspace/plugins.cljs:228
msgid "workspace.plugins.install"
msgstr "Install"
#: src/app/main/ui/workspace/plugins.cljs:231
#: src/app/main/ui/workspace/plugins.cljs:258
msgid "workspace.plugins.installed-plugins"
msgstr "Installed plugins"
@ -8140,102 +8146,112 @@ msgstr "Plugins manager"
msgid "workspace.plugins.menu.title"
msgstr "Plugins"
#: src/app/main/ui/workspace/plugins.cljs:424
#: src/app/main/ui/workspace/plugins.cljs:452
msgid "workspace.plugins.permissions-update.title"
msgstr "UPDATE THIS PLUGIN"
#: src/app/main/ui/workspace/plugins.cljs:428
#: src/app/main/ui/workspace/plugins.cljs:456
msgid "workspace.plugins.permissions-update.warning"
msgstr ""
"The plugin has been modified since you last opened it. It now also wants to "
"access:"
#: src/app/main/ui/workspace/plugins.cljs:297
#: src/app/main/ui/workspace/plugins.cljs:325
msgid "workspace.plugins.permissions.allow-download"
msgstr "Start file downloads."
#: src/app/main/ui/workspace/plugins.cljs:304
#: src/app/main/ui/workspace/plugins.cljs:332
msgid "workspace.plugins.permissions.allow-localstorage"
msgstr "Store data in the browser."
#: src/app/main/ui/workspace/plugins.cljs:317
#: src/app/main/ui/workspace/plugins.cljs:345
msgid "workspace.plugins.permissions.clipboard-read"
msgstr "Read the contents of your clipboard."
#: src/app/main/ui/workspace/plugins.cljs:311
#: src/app/main/ui/workspace/plugins.cljs:339
msgid "workspace.plugins.permissions.clipboard-write"
msgstr "Read and write to your clipboard."
#: src/app/main/ui/workspace/plugins.cljs:290
#: src/app/main/ui/workspace/plugins.cljs:318
msgid "workspace.plugins.permissions.comment-read"
msgstr "Read your comments and replies."
#: src/app/main/ui/workspace/plugins.cljs:284
#: src/app/main/ui/workspace/plugins.cljs:312
msgid "workspace.plugins.permissions.comment-write"
msgstr "Read and modify your comments and reply in your name."
#: src/app/main/ui/workspace/plugins.cljs:257
#: src/app/main/ui/workspace/plugins.cljs:285
msgid "workspace.plugins.permissions.content-read"
msgstr "Read the content of files that users have access to."
#: src/app/main/ui/workspace/plugins.cljs:251
#: src/app/main/ui/workspace/plugins.cljs:279
msgid "workspace.plugins.permissions.content-write"
msgstr "Read and modify the content of files that users have access to."
#: src/app/main/ui/workspace/plugins.cljs:366
#: src/app/main/ui/workspace/plugins.cljs:394
msgid "workspace.plugins.permissions.disclaimer"
msgstr ""
"Please note that this plugin is created by an external party, so ensure you "
"trust it before granting access. Your data privacy and security are "
"important to us. If you have any concerns, please contact support."
#: src/app/main/ui/workspace/plugins.cljs:277
#: src/app/main/ui/workspace/plugins.cljs:305
msgid "workspace.plugins.permissions.library-read"
msgstr "Read your libraries and assets."
#: src/app/main/ui/workspace/plugins.cljs:271
#: src/app/main/ui/workspace/plugins.cljs:299
msgid "workspace.plugins.permissions.library-write"
msgstr "Read and modify your libraries and assets."
#: src/app/main/ui/workspace/plugins.cljs:359
#: src/app/main/ui/workspace/plugins.cljs:387
msgid "workspace.plugins.permissions.title"
msgstr "'%s' PLUGIN WANTS ACCESS TO:"
#: src/app/main/ui/workspace/plugins.cljs:264
#: src/app/main/ui/workspace/plugins.cljs:292
msgid "workspace.plugins.permissions.user-read"
msgstr "Read the profile information of the current user."
#: src/app/main/ui/workspace/plugins.cljs:227
#: src/app/main/ui/workspace/plugins.cljs:254
msgid "workspace.plugins.plugin-list-link"
msgstr "Plugins List"
#: src/app/main/ui/workspace/plugins.cljs:101
#: src/app/main/ui/workspace/plugins.cljs:191
msgid "workspace.plugins.remove-confirmation.message"
msgstr ""
"Are you sure you want to remove the plugin %s? You can install it again at "
"any time."
#: src/app/main/ui/workspace/plugins.cljs:190
msgid "workspace.plugins.remove-confirmation.title"
msgstr "Remove plugin"
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:192
msgid "workspace.plugins.remove-plugin"
msgstr "Remove plugin"
#: src/app/main/ui/workspace/plugins.cljs:195
#: src/app/main/ui/workspace/plugins.cljs:222
msgid "workspace.plugins.search-placeholder"
msgstr "Write a plugin URL"
#: src/app/main/ui/workspace/plugins.cljs:188
#: src/app/main/ui/workspace/plugins.cljs:215
msgid "workspace.plugins.title"
msgstr "Plugins"
#: src/app/main/ui/workspace/plugins.cljs:494
#: src/app/main/ui/workspace/plugins.cljs:522
msgid "workspace.plugins.try-out.cancel"
msgstr "NOT NOW"
#: src/app/main/ui/workspace/plugins.cljs:487
#: src/app/main/ui/workspace/plugins.cljs:515
msgid "workspace.plugins.try-out.message"
msgstr ""
"Want to take a look? It will open in a new draft for your current team. (If "
"not, you can always find it in the installed plugins of any file.)"
#: src/app/main/ui/workspace/plugins.cljs:483
#: src/app/main/ui/workspace/plugins.cljs:511
msgid "workspace.plugins.try-out.title"
msgstr "'%s' PLUGIN IS INSTALLED FOR YOUR USER!"
#: src/app/main/ui/workspace/plugins.cljs:500
#: src/app/main/ui/workspace/plugins.cljs:528
msgid "workspace.plugins.try-out.try"
msgstr "TRY PLUGIN"

View File

@ -1452,11 +1452,11 @@ msgstr "Atención"
msgid "ds.component-subtitle"
msgstr "Componentes a actualizar:"
#: src/app/main/ui/workspace/plugins.cljs:379, src/app/main/ui/workspace/plugins.cljs:442
#: src/app/main/ui/workspace/plugins.cljs:407, src/app/main/ui/workspace/plugins.cljs:470
msgid "ds.confirm-allow"
msgstr "Permitir"
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:373, src/app/main/ui/workspace/plugins.cljs:436
#: src/app/main/ui/comments.cljs:719, src/app/main/ui/confirm.cljs:43, src/app/main/ui/settings/subscription.cljs:327, src/app/main/ui/settings/subscription.cljs:360, src/app/main/ui/settings/subscription.cljs:844, src/app/main/ui/settings/subscription.cljs:893, src/app/main/ui/settings/subscription.cljs:903, src/app/main/ui/workspace/plugins.cljs:401, src/app/main/ui/workspace/plugins.cljs:464
msgid "ds.confirm-cancel"
msgstr "Cancelar"
@ -1538,25 +1538,25 @@ msgstr ""
"Acceso al portapapeles denegado. Permite el acceso al portapapeles en tu "
"navegador para pegar contenido."
#: src/app/main/errors.cljs:773
#: src/app/main/errors.cljs:782
msgid "errors.comment-error"
msgstr "Ha habido un error con el comentario"
#: src/app/main/errors.cljs:842
#: src/app/main/errors.cljs:851
msgid "errors.deprecated"
msgstr ""
"¡Lo sentimos! Este es un fichero antiguo que utiliza un tipo de recurso de "
"Penpot obsoleto, y no puedes abrirlo."
#: src/app/main/errors.cljs:845
#: src/app/main/errors.cljs:854
msgid "errors.deprecated.contact.after"
msgstr "para que podamos ayudarte."
#: src/app/main/errors.cljs:843
#: src/app/main/errors.cljs:852
msgid "errors.deprecated.contact.before"
msgstr "Aunque Penpot ya no da soporte a este tipo de Recursos, puedes"
#: src/app/main/errors.cljs:844
#: src/app/main/errors.cljs:853
msgid "errors.deprecated.contact.text"
msgstr "contactar con nosotros"
@ -1592,13 +1592,13 @@ msgstr "El correo electrónico «%s» tiene varios reportes de rebote permanente
msgid "errors.email-spam-or-permanent-bounces"
msgstr "El email «%s» tiene reportes de spam o de rebote permanente."
#: src/app/main/errors.cljs:819
#: src/app/main/errors.cljs:828
msgid "errors.feature-mismatch"
msgstr ""
"Parece que está abriendo un archivo que tiene la función '%s' habilitada, "
"pero la versión actual de penpot no la admite o la tiene deshabilitada."
#: src/app/main/errors.cljs:823, src/app/main/errors.cljs:837
#: src/app/main/errors.cljs:832, src/app/main/errors.cljs:846
msgid "errors.feature-not-supported"
msgstr "Caracteristica no soportada: '%s'."
@ -1618,7 +1618,7 @@ msgstr "Campo vacio"
msgid "errors.field-not-all-whitespace"
msgstr "Debe contener algún carácter diferente de espacio."
#: src/app/main/errors.cljs:815
#: src/app/main/errors.cljs:824
msgid "errors.file-feature-mismatch"
msgstr ""
"Parece que hay discordancia entre las features habilitadas y las features "
@ -1631,11 +1631,11 @@ msgstr ""
"El nombre de la familia tipográfica solo puede contener letras, números, "
"espacios, guiones, guiones bajos y puntos."
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:158, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
#: src/app/main/data/auth.cljs:352, src/app/main/errors.cljs:479, src/app/main/ui/auth/login.cljs:116, src/app/main/ui/auth/register.cljs:131, src/app/main/ui/auth/register.cljs:317, src/app/main/ui/auth/verify_token.cljs:131, src/app/main/ui/dashboard/fonts.cljs:386, src/app/main/ui/dashboard/team.cljs:250, src/app/main/ui/dashboard/team.cljs:1131, src/app/main/ui/onboarding/team_choice.cljs:125, src/app/main/ui/settings/feedback.cljs:84, src/app/main/ui/settings/integrations.cljs:143, src/app/main/ui/workspace/main_menu.cljs:966
msgid "errors.generic"
msgstr "Ha ocurrido algún error."
#: src/app/main/errors.cljs:752
#: src/app/main/errors.cljs:761
msgid "errors.internal-worker-error"
msgstr "Ha ocurrido un problema con el web worker."
@ -1687,7 +1687,7 @@ msgstr "Esta invitación puede haber sido cancelada o ha expirado."
msgid "errors.ldap-disabled"
msgstr "La autheticacion via LDAP esta deshabilitada."
#: src/app/main/errors.cljs:831, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
#: src/app/main/errors.cljs:840, src/app/main/ui/dashboard/team.cljs:234, src/app/main/ui/dashboard/team.cljs:1123, src/app/main/ui/onboarding/team_choice.cljs:117
msgid "errors.max-quota-reached"
msgstr "Ha alcalzando el maximo de la quota '%s'. Contacte con soporte tecnico."
@ -1717,7 +1717,7 @@ msgstr ""
"El perfil que esta invitando tiene los emails silenciados (por reportes de "
"spam o alto índice de rebote)."
#: src/app/main/errors.cljs:805
#: src/app/main/errors.cljs:814
msgid "errors.migration-in-progress"
msgstr "Migración en proceso"
@ -1765,6 +1765,12 @@ msgstr ""
"Tu perfil tiene los emails silenciados (por reportes de spam o alto índice "
"de rebote)."
#: src/app/main/errors.cljs:724
msgid "errors.profile-props-too-large"
msgstr ""
"La configuración de tu perfil es demasiado grande y no se puede guardar. "
"Elimina algunas extensiones e inténtalo de nuevo."
#: src/app/main/data/auth.cljs:340, src/app/main/ui/auth/register.cljs:96
msgid "errors.registration-disabled"
msgstr "El registro está actualmente desactivado."
@ -1776,11 +1782,11 @@ msgstr ""
"informar del error y recarga para continuar desde los últimos cambios "
"guardados."
#: src/app/main/errors.cljs:764
#: src/app/main/errors.cljs:773
msgid "errors.svg-parser.invalid-svg"
msgstr "El SVG no es válido o está mal formado"
#: src/app/main/errors.cljs:810
#: src/app/main/errors.cljs:819
msgid "errors.team-feature-mismatch"
msgstr "Detectada funcionalidad incompatible '%s'"
@ -1964,7 +1970,7 @@ msgstr "Ha ocurrido un error inesperado."
msgid "errors.unexpected-token"
msgstr "Token desconocido"
#: src/app/main/errors.cljs:723
#: src/app/main/errors.cljs:732
msgid "errors.version-already-locked"
msgstr "Esta versión ya está bloqueada"
@ -1972,7 +1978,7 @@ msgstr "Esta versión ya está bloqueada"
msgid "errors.version-locked"
msgstr "Esta versión está bloqueada y otras personas no pueden eliminarla"
#: src/app/main/errors.cljs:827
#: src/app/main/errors.cljs:836
msgid "errors.version-not-supported"
msgstr "El fichero tiene un número de versión incompatible"
@ -8169,16 +8175,16 @@ msgstr "Alinear nodos (%s)"
msgid "workspace.plugins.button-open"
msgstr "Abrir"
#: src/app/main/ui/workspace/plugins.cljs:215
#: src/app/main/ui/workspace/plugins.cljs:242
#, markdown
msgid "workspace.plugins.discover"
msgstr "Descubre [más extensiones](%s)"
#: src/app/main/ui/workspace/plugins.cljs:222
#: src/app/main/ui/workspace/plugins.cljs:249
msgid "workspace.plugins.empty-plugins"
msgstr "No se encuentran extensiones"
#: src/app/main/ui/workspace/plugins.cljs:209
#: src/app/main/ui/workspace/plugins.cljs:236
msgid "workspace.plugins.error.manifest"
msgstr "El manifiesto de la expansión es incorrecto."
@ -8190,15 +8196,15 @@ msgstr "Debes ser un editor para usar este plugin"
msgid "workspace.plugins.error.unreachable"
msgstr "No se ha podido acceder a la URL de la extensión."
#: src/app/main/ui/workspace/plugins.cljs:205
#: src/app/main/ui/workspace/plugins.cljs:232
msgid "workspace.plugins.error.url"
msgstr "La extensión no existe o la url no es correcta."
#: src/app/main/ui/workspace/plugins.cljs:201
#: src/app/main/ui/workspace/plugins.cljs:228
msgid "workspace.plugins.install"
msgstr "Instalar"
#: src/app/main/ui/workspace/plugins.cljs:231
#: src/app/main/ui/workspace/plugins.cljs:258
msgid "workspace.plugins.installed-plugins"
msgstr "Extensiones instaladas"
@ -8210,49 +8216,49 @@ msgstr "Gestor de extensiones"
msgid "workspace.plugins.menu.title"
msgstr "Extensiones"
#: src/app/main/ui/workspace/plugins.cljs:424
#: src/app/main/ui/workspace/plugins.cljs:452
msgid "workspace.plugins.permissions-update.title"
msgstr "EXTENSIÓN ACTUALIZADA"
#: src/app/main/ui/workspace/plugins.cljs:428
#: src/app/main/ui/workspace/plugins.cljs:456
msgid "workspace.plugins.permissions-update.warning"
msgstr ""
"La extensión ha cambiado desde la última vez que la abriste. Ahora quiere "
"acceder a:"
#: src/app/main/ui/workspace/plugins.cljs:297
#: src/app/main/ui/workspace/plugins.cljs:325
msgid "workspace.plugins.permissions.allow-download"
msgstr "Comenzar descargas de ficheros."
#: src/app/main/ui/workspace/plugins.cljs:304
#: src/app/main/ui/workspace/plugins.cljs:332
msgid "workspace.plugins.permissions.allow-localstorage"
msgstr "Guardar datos en el navegador."
#: src/app/main/ui/workspace/plugins.cljs:317
#: src/app/main/ui/workspace/plugins.cljs:345
msgid "workspace.plugins.permissions.clipboard-read"
msgstr "Leer el contenido de tu portapapeles."
#: src/app/main/ui/workspace/plugins.cljs:311
#: src/app/main/ui/workspace/plugins.cljs:339
msgid "workspace.plugins.permissions.clipboard-write"
msgstr "Leer y escribir en tu portapapeles."
#: src/app/main/ui/workspace/plugins.cljs:290
#: src/app/main/ui/workspace/plugins.cljs:318
msgid "workspace.plugins.permissions.comment-read"
msgstr "Leer tus comentarios y respuestas."
#: src/app/main/ui/workspace/plugins.cljs:284
#: src/app/main/ui/workspace/plugins.cljs:312
msgid "workspace.plugins.permissions.comment-write"
msgstr "Leer y modificar tus comentarios y responder en tu nombre."
#: src/app/main/ui/workspace/plugins.cljs:257
#: src/app/main/ui/workspace/plugins.cljs:285
msgid "workspace.plugins.permissions.content-read"
msgstr "Leer el contenido de sus archivos."
#: src/app/main/ui/workspace/plugins.cljs:251
#: src/app/main/ui/workspace/plugins.cljs:279
msgid "workspace.plugins.permissions.content-write"
msgstr "Leer y modificar el contenido de sus archivos."
#: src/app/main/ui/workspace/plugins.cljs:366
#: src/app/main/ui/workspace/plugins.cljs:394
msgid "workspace.plugins.permissions.disclaimer"
msgstr ""
"Ten en cuenta que las extensiones están desarrolladas por terceros, "
@ -8260,54 +8266,64 @@ msgstr ""
"seguridad es importante para nosotros. Si tienes cualquier duda, contacta "
"con soporte."
#: src/app/main/ui/workspace/plugins.cljs:277
#: src/app/main/ui/workspace/plugins.cljs:305
msgid "workspace.plugins.permissions.library-read"
msgstr "Leer la información de sus bibliotecas y recursos."
#: src/app/main/ui/workspace/plugins.cljs:271
#: src/app/main/ui/workspace/plugins.cljs:299
msgid "workspace.plugins.permissions.library-write"
msgstr "Leer y modificar la información de sus bibliotecas y recursos."
#: src/app/main/ui/workspace/plugins.cljs:359
#: src/app/main/ui/workspace/plugins.cljs:387
msgid "workspace.plugins.permissions.title"
msgstr "LA EXTENSIÓN '%s' SOLICITA PERMISO PARA ACCEDER:"
#: src/app/main/ui/workspace/plugins.cljs:264
#: src/app/main/ui/workspace/plugins.cljs:292
msgid "workspace.plugins.permissions.user-read"
msgstr "Leer la información del usuario actual."
#: src/app/main/ui/workspace/plugins.cljs:227
#: src/app/main/ui/workspace/plugins.cljs:254
msgid "workspace.plugins.plugin-list-link"
msgstr "Lista de extensiones"
#: src/app/main/ui/workspace/plugins.cljs:101
#: src/app/main/ui/workspace/plugins.cljs:191
msgid "workspace.plugins.remove-confirmation.message"
msgstr ""
"¿Seguro que quieres eliminar la extensión %s? Puedes instalarla de nuevo en "
"cualquier momento."
#: src/app/main/ui/workspace/plugins.cljs:190
msgid "workspace.plugins.remove-confirmation.title"
msgstr "Eliminar extensión"
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:192
msgid "workspace.plugins.remove-plugin"
msgstr "Eliminar extensión"
#: src/app/main/ui/workspace/plugins.cljs:195
#: src/app/main/ui/workspace/plugins.cljs:222
msgid "workspace.plugins.search-placeholder"
msgstr "Intruduzca URL de la extensión"
#: src/app/main/ui/workspace/plugins.cljs:188
#: src/app/main/ui/workspace/plugins.cljs:215
msgid "workspace.plugins.title"
msgstr "Extensiones"
#: src/app/main/ui/workspace/plugins.cljs:494
#: src/app/main/ui/workspace/plugins.cljs:522
msgid "workspace.plugins.try-out.cancel"
msgstr "AHORA NO"
#: src/app/main/ui/workspace/plugins.cljs:487
#: src/app/main/ui/workspace/plugins.cljs:515
msgid "workspace.plugins.try-out.message"
msgstr ""
"¿Quieres echar un vistazo?. Crearemos un nuevo borrador en tu equipo "
"actual. (Si no, puedes encontrar los plugins instalados en cualquier "
"fichero.)"
#: src/app/main/ui/workspace/plugins.cljs:483
#: src/app/main/ui/workspace/plugins.cljs:511
msgid "workspace.plugins.try-out.title"
msgstr "¡LA EXTENSIÓN '%s' HA SIDO INSTALADA PARA TU USUARIO!"
#: src/app/main/ui/workspace/plugins.cljs:500
#: src/app/main/ui/workspace/plugins.cljs:528
msgid "workspace.plugins.try-out.try"
msgstr "PROBAR PLUGIN"

View File

@ -94,6 +94,9 @@ set -e
# instance-compose wrap 'docker compose' for one instance's main
# project, injecting that instance's overrides
# instance-env-overrides the per-instance KEY=VALUE overrides
# instance-test-db-name test database name for one instance
# instance-test-redis-db Valkey DB number for one instance's tests
# ensure-instance-test-database create one instance's test database
# devenv-main-container resolve the 'main' container id via compose ps
# devenv-main-running true if 'main' is up
#
@ -301,6 +304,43 @@ function ensure-infra-up {
infra-compose up -d --wait --wait-timeout 60 --remove-orphans
}
# Create the backend-test database for <instance> on the shared Postgres
# if it does not exist yet. Idempotent: safe to run on every bring-up.
# Needed because postgresql_init.sql only runs when the Postgres volume is
# created for the first time, so per-instance test databases (which did not
# exist back then) would otherwise never be created on existing volumes.
# The backend applies migrations itself on first use, so an empty database
# is enough here. (Two plain SQL round-trips on purpose: psql -c does not
# reliably mix SQL with psql-only commands like \gexec.)
function ensure-instance-test-database {
local instance="$1"
local dbname
dbname=$(instance-test-db-name "$instance")
local pg_container
pg_container=$(infra-compose ps -q postgres 2>/dev/null)
if [[ -z "$pg_container" ]]; then
echo "[${instance}] postgres container not found; is shared infra up?" >&2
return 1
fi
local -a psql_base=(docker exec
-e "PGPASSWORD=${PENPOT_DATABASE_PASSWORD:-penpot}"
"$pg_container"
psql -h 127.0.0.1 -U "${PENPOT_DATABASE_USERNAME:-penpot}"
-d postgres -v ON_ERROR_STOP=1 -tA)
local exists
exists=$("${psql_base[@]}" -c "SELECT 1 FROM pg_database WHERE datname = '${dbname}'")
if [[ "$exists" == "1" ]]; then
echo "[${instance}] test database ${dbname} already exists."
return 0
fi
echo "[${instance}] creating test database ${dbname} ..."
"${psql_base[@]}" -c "CREATE DATABASE \"${dbname}\""
}
# Refuse to sync workspaces if the live repo is in a fragile Git state.
# Copying a partial rebase/merge/cherry-pick into all workspaces would leave
# every instance in the same broken state.
@ -333,6 +373,33 @@ function instance-port {
echo $(( base + n * PENPOT_INSTANCE_PORT_STRIDE ))
}
# Echo the backend-test database name for <instance>. ws0 keeps the
# historical name so existing flows stay untouched; ws1+ get an isolated
# database each on the shared Postgres server. The main database stays
# shared by all instances on purpose.
function instance-test-db-name {
local instance="$1"
local n=0
[[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
if (( n == 0 )); then
echo "penpot_test"
else
echo "penpot_test_ws${n}"
fi
}
# Echo the Valkey DB number reserved for the backend tests of <instance>.
# Runtime backends already occupy 0..PENPOT_MAX_WS_INDEX on the shared
# Valkey, so test DBs start right after to never share a DB with a
# running backend. (Resolved at call time; PENPOT_MAX_WS_INDEX is defined
# further below but always set before any call.)
function instance-test-redis-db {
local instance="$1"
local n=0
[[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
echo $(( PENPOT_MAX_WS_INDEX + 1 + n ))
}
# Echo the per-instance Compose variable overrides for a workspace, one
# KEY=VALUE per line, for instance-compose to inject into its `env -i` line.
# Compose gives shell-env precedence over --env-file, so these override the
@ -358,11 +425,15 @@ function instance-env-overrides {
opencode=$(instance-port "$instance" "$PENPOT_PORT_BASE_OPENCODE")
mdts=$(instance-port "$instance" "$PENPOT_PORT_BASE_MDTS")
storybook=$(instance-port "$instance" "$PENPOT_PORT_BASE_STORYBOOK")
test_db=$(instance-test-db-name "$instance")
test_redis_db=$(instance-test-redis-db "$instance")
printf '%s\n' \
"PENPOT_MAIN_CONTAINER_NAME=penpot-devenv-${instance}-main" \
"PENPOT_USER_DATA_VOLUME=penpotdev_${instance}_user_data" \
"PENPOT_PUBLIC_URI=https://localhost:${public_https}" \
"PENPOT_REDIS_URI=redis://valkey/${n}" \
"PENPOT_TEST_DATABASE_URI=postgresql://postgres/${test_db}" \
"PENPOT_TEST_REDIS_URI=redis://valkey/${test_redis_db}" \
"PENPOT_PUBLIC_HTTPS_PORT=${public_https}" \
"PENPOT_PUBLIC_HTTP_PORT=${public}" \
"PENPOT_MCP_SERVER_PORT=${mcp}" \
@ -709,6 +780,8 @@ function start-instance {
local git_user_email="${4:-}"
local agentic="${5:-true}"
ensure-instance-test-database "$instance"
instance-compose "$instance" up -d main
# Wait briefly for main to be reachable; the tmux session lives inside.

View File

@ -434,26 +434,6 @@ pub fn resize_matrix(
child_bounds: &Bounds,
new_width: f32,
new_height: f32,
) -> Matrix {
resize_matrix_from(
parent_bounds,
child_bounds,
new_width,
new_height,
child_bounds.nw,
)
}
/*
* Like `resize_matrix`, but scaling about an explicit corner instead of `nw`.
* Rtl auto-width text anchors on `ne` so it extends leftward as it grows.
*/
pub fn resize_matrix_from(
parent_bounds: &Bounds,
child_bounds: &Bounds,
new_width: f32,
new_height: f32,
anchor: Point,
) -> Matrix {
let mut result = Matrix::default();
@ -475,7 +455,7 @@ pub fn resize_matrix_from(
parent_transform.pre_translate(-center);
let parent_transform_inv = &parent_transform.invert().unwrap_or_default();
let origin = parent_transform_inv.map_point(anchor);
let origin = parent_transform_inv.map_point(child_bounds.nw);
let mut scale = Matrix::scale((scale_width, scale_height));
scale.post_translate(origin);
@ -617,40 +597,4 @@ mod tests {
assert!((m.translate_x() - 0.0).abs() <= 0.1);
assert!((m.translate_y() - 0.0).abs() <= 0.1);
}
fn axis_aligned_bounds(x: f32, y: f32, w: f32, h: f32) -> Bounds {
Bounds::new(
Point::new(x, y),
Point::new(x + w, y),
Point::new(x + w, y + h),
Point::new(x, y + h),
)
}
#[test]
fn resize_matrix_keeps_the_north_west_corner() {
let bounds = axis_aligned_bounds(100.0, 50.0, 60.0, 20.0);
let matrix = resize_matrix(&bounds, &bounds, 120.0, 20.0);
let resized = bounds.transform(&matrix);
assert!(is_close_to(resized.nw.x, 100.0));
assert!(is_close_to(resized.ne.x, 220.0));
}
#[test]
fn resize_matrix_from_north_east_keeps_the_right_edge() {
let bounds = axis_aligned_bounds(100.0, 50.0, 60.0, 20.0);
let matrix = resize_matrix_from(&bounds, &bounds, 120.0, 20.0, bounds.ne);
let resized = bounds.transform(&matrix);
assert!(is_close_to(resized.ne.x, 160.0), "right edge preserved");
assert!(is_close_to(resized.nw.x, 40.0), "grew leftward");
assert!(is_close_to(resized.nw.y, 50.0), "top edge preserved");
}
#[test]
fn resize_matrix_from_north_west_matches_the_default() {
let bounds = axis_aligned_bounds(100.0, 50.0, 60.0, 20.0);
let default = resize_matrix(&bounds, &bounds, 120.0, 40.0);
let explicit = resize_matrix_from(&bounds, &bounds, 120.0, 40.0, bounds.nw);
assert_eq!(default, explicit);
}
}

View File

@ -2,25 +2,9 @@ use crate::render::options::RenderOptions;
use crate::shapes::{vertical_align_offset, Shape, TextContent, Type};
use crate::state::{TextEditorState, TextSelection};
use crate::view::Viewbox;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle, TextBox, TextDirection};
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{BlendMode, Canvas, Color, Paint, Rect};
/// Caret x where a character typed *before* this glyph would land.
fn leading_edge(text_box: &TextBox) -> f32 {
match text_box.direct {
TextDirection::RTL => text_box.rect.right(),
_ => text_box.rect.left(),
}
}
/// Caret x where a character typed *after* this glyph would land.
fn trailing_edge(text_box: &TextBox) -> f32 {
match text_box.direct {
TextDirection::RTL => text_box.rect.left(),
_ => text_box.rect.right(),
}
}
pub fn render_overlay(
canvas: &Canvas,
viewbox: &Viewbox,
@ -128,14 +112,6 @@ fn render_selection(
canvas.restore();
}
/// Caret and selection rects are drawn through `shape.get_matrix()`, which
/// translates by the *selrect's* `left_top()`; this shifts them onto the text.
fn paragraphs_horizontal_offset(shape: &Shape, text_content: &TextContent) -> f32 {
let selrect = shape.selrect();
let width = text_content.get_width(selrect.width());
text_content.layout_origin_x(&selrect, width) - selrect.x()
}
fn paragraphs_vertical_offset(
shape: &Shape,
layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
@ -165,7 +141,6 @@ fn calculate_cursor_rect(
return None;
}
let x_offset = paragraphs_horizontal_offset(shape, text_content);
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
@ -183,13 +158,8 @@ fn calculate_cursor_rect(
// Skia ranges are UTF-16 code units, not characters.
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
// No glyph to anchor to: sit where the first character will appear.
let empty_x = if para.text_direction() == TextDirection::RTL {
laid_out_para.max_width()
} else {
0.0
};
(empty_x, 0.0, 1.0, laid_out_para.height())
// Empty paragraph - use default height
(0.0, 0.0, 1.0, laid_out_para.height())
} else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range(
0..para.char_utf16_len_at(0),
@ -198,7 +168,7 @@ fn calculate_cursor_rect(
);
if !rects.is_empty() {
let r = &rects[0].rect;
(leading_edge(&rects[0]), r.top(), r.width(), r.height())
(r.left(), r.top(), r.width(), r.height())
} else {
(0.0, 0.0, 1.0, laid_out_para.height())
}
@ -212,15 +182,14 @@ fn calculate_cursor_rect(
);
if !rects.is_empty() {
let r = &rects[0].rect;
(trailing_edge(&rects[0]), r.top(), r.width(), r.height())
(r.right(), r.top(), r.width(), r.height())
} else if let Some(line) = laid_out_para.get_line_metrics().last() {
// No glyph box to measure: use the end of the line.
let line_end = if para.text_direction() == TextDirection::RTL {
line.left as f32
} else {
line.left as f32 + line.width as f32
};
(line_end, 0.0, 1.0, laid_out_para.height())
(
line.left as f32 + line.width as f32,
0.0,
1.0,
laid_out_para.height(),
)
} else {
(0.0, 0.0, 1.0, laid_out_para.height())
}
@ -233,7 +202,7 @@ fn calculate_cursor_rect(
);
if !rects.is_empty() {
let r = &rects[0].rect;
(leading_edge(&rects[0]), r.top(), r.width(), r.height())
(r.left(), r.top(), r.width(), r.height())
} else {
// Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
@ -242,7 +211,7 @@ fn calculate_cursor_rect(
};
return Some(Rect::from_xywh(
x_offset + cursor_x,
cursor_x,
y_offset + cursor_y,
cursor_width, // cursor_width
cursor_height,
@ -267,7 +236,6 @@ fn calculate_selection_rects(
let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let x_offset = paragraphs_horizontal_offset(shape, text_content);
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
@ -310,7 +278,7 @@ fn calculate_selection_rects(
for text_box in text_boxes {
let r = text_box.rect;
rects.push(Rect::from_xywh(
x_offset + r.left(),
r.left(),
y_offset + r.top(),
r.width(),
r.height(),
@ -323,92 +291,3 @@ fn calculate_selection_rects(
rects
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{FontFamily, FontStyle, GrowType, Paragraph, TextAlign, TextSpan};
use crate::uuid::Uuid;
fn rtl_span() -> TextSpan {
TextSpan::new(
"نام".to_string(),
FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
14.0,
1.2,
0.0,
None,
None,
TextDirection::RTL,
400,
Uuid::nil(),
vec![],
)
}
/// Auto-width rtl content measured wider than its stale selrect: mid-edit,
/// where the overlay origin and the selrect diverge.
fn grown_rtl_shape(measured_width: f32) -> Shape {
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let mut content = TextContent::new(selrect, GrowType::AutoWidth);
content.add_paragraph(Paragraph::new(
TextAlign::Right,
TextDirection::RTL,
None,
None,
1.2,
0.0,
vec![rtl_span()],
));
content.size.width = measured_width;
content.size.height = 20.0;
let mut shape = Shape::new(Uuid::nil());
shape.set_selrect(selrect.left, selrect.top, selrect.right, selrect.bottom);
shape.set_shape_type(Type::Text(content));
shape
}
fn text_content_of(shape: &Shape) -> &TextContent {
match &shape.shape_type {
Type::Text(content) => content,
_ => unreachable!(),
}
}
#[test]
fn horizontal_offset_shifts_the_overlay_onto_grown_rtl_text() {
let shape = grown_rtl_shape(120.0);
// right edge 160 - measured 120 = 40, i.e. 60 left of selrect.x
assert_eq!(
paragraphs_horizontal_offset(&shape, text_content_of(&shape)),
-60.0
);
}
#[test]
fn horizontal_offset_is_zero_once_the_selrect_is_committed() {
let shape = grown_rtl_shape(60.0);
assert_eq!(
paragraphs_horizontal_offset(&shape, text_content_of(&shape)),
0.0
);
}
#[test]
fn leading_and_trailing_edges_follow_the_run_direction() {
let rect = Rect::from_ltrb(10.0, 0.0, 30.0, 12.0);
let ltr = TextBox {
rect,
direct: TextDirection::LTR,
};
let rtl = TextBox {
rect,
direct: TextDirection::RTL,
};
assert_eq!(leading_edge(&ltr), 10.0);
assert_eq!(trailing_edge(&ltr), 30.0);
assert_eq!(leading_edge(&rtl), 30.0);
assert_eq!(trailing_edge(&rtl), 10.0);
}
}

View File

@ -320,18 +320,11 @@ fn propagate_transform(
}
}
}
// Rtl text keeps its right edge, so scale about the ne corner.
let anchor = if text_content.is_rtl() {
shape_bounds_after.ne
} else {
shape_bounds_after.nw
};
let resize_transform = math::resize_matrix_from(
let resize_transform = math::resize_matrix(
&shape_bounds_after,
&shape_bounds_after,
new_width,
new_height,
anchor,
);
shape_bounds_after = shape_bounds_after.transform(&resize_transform);
transform.post_concat(&resize_transform);

View File

@ -476,26 +476,18 @@ impl TextContent {
/// [`cached_layout_paint_offset`] on the canvas so both move together.
pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point {
self.layout_paint_origin
.unwrap_or_else(|| Point::new(self.selrect_origin_x(selrect), selrect.y()))
.unwrap_or_else(|| Point::new(selrect.x(), selrect.y()))
}
/// Canvas translation from the baked paint origin to the current selrect.
/// Zero when there is no recorded origin (fall back to painting at selrect).
pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point {
match self.layout_paint_origin {
Some(origin) => Point::new(
self.selrect_origin_x(selrect) - origin.x,
selrect.y() - origin.y,
),
Some(origin) => Point::new(selrect.x() - origin.x, selrect.y() - origin.y),
None => Point::new(0.0, 0.0),
}
}
/// [`layout_origin_x`] for this content's own measured width.
fn selrect_origin_x(&self, selrect: &Rect) -> f32 {
self.layout_origin_x(selrect, self.size.width)
}
/// Text content for paint when [`Rect`] size may differ from stored bounds
/// (e.g. modifier transform). Reuses `self` when width/height match; otherwise
/// clones paragraphs into a rebound copy with an empty layout cache.
@ -576,26 +568,6 @@ impl TextContent {
}
}
/// Non-empty and every paragraph RTL. Mixed content counts as LTR, mirroring
/// `app.common.types.text/rtl-content?` on the ClojureScript side.
pub fn is_rtl(&self) -> bool {
!self.paragraphs.is_empty()
&& self
.paragraphs
.iter()
.all(|paragraph| paragraph.text_direction() == TextDirection::RTL)
}
/// Left edge of the laid-out text of `width` inside `selrect`. RTL auto-width
/// text anchors right so the box extends leftward as it grows.
pub fn layout_origin_x(&self, selrect: &Rect, width: f32) -> f32 {
if self.grow_type() == GrowType::AutoWidth && self.is_rtl() {
selrect.right() - width
} else {
selrect.x()
}
}
/// Compute a tight text rect from laid-out Skia paragraphs using glyph
/// metrics (fm.top for overshoot, line descent for bottom, line left/width
/// for horizontal extent).
@ -748,6 +720,7 @@ impl TextContent {
}
pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect {
let x = selrect.x();
let mut y = selrect.y();
let width = if self.grow_type() == GrowType::AutoWidth {
@ -756,8 +729,6 @@ impl TextContent {
selrect.width()
};
let x = self.layout_origin_x(selrect, width);
let height = if self.size.width.round() != width.round() {
self.get_height(width)
} else {
@ -1182,8 +1153,8 @@ impl TextContent {
self.layout.set(result.0, result.1);
self.size
.copy_finite_size(result.2, default_width, default_height);
// Absolute image/gradient shaders are built against `self.bounds()`, so the
// origin matches them and `cached_layout_paint_offset` carries any rtl shift.
// Paragraph paints (incl. absolute image/gradient shaders) were built
// against `self.bounds()` in `paragraph_builder_group_from_text`.
self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y()));
}
@ -1853,7 +1824,7 @@ pub fn calculate_text_layout_data(
let selrect_width = shape.selrect().width();
let text_width = text_content.get_width(selrect_width);
let selrect_height = shape.selrect().height();
let x = text_content.layout_origin_x(&shape.selrect, text_width);
let x = shape.selrect.x();
let base_y = shape.selrect.y();
let mut position_data: Vec<PositionData> = Vec::new();
let mut previous_line_height = text_content.normalized_line_height();
@ -2412,170 +2383,4 @@ mod tests {
layout.clear();
assert!(layout.needs_update());
}
// -----------------------------------------------------------------------
// RTL auto-width growth anchor
// -----------------------------------------------------------------------
fn directed_paragraph(direction: TextDirection) -> Paragraph {
let span = TextSpan::new(
"hello".to_string(),
FontFamily::new(Uuid::nil(), 400, crate::shapes::FontStyle::Normal),
14.0,
1.2,
0.0,
None,
None,
direction,
400,
Uuid::nil(),
vec![],
);
Paragraph::new(TextAlign::Left, direction, None, None, 1.2, 0.0, vec![span])
}
/// Auto-width content measured wider than its stale selrect: mid-edit, where
/// the anchor is observable.
fn grown_content(directions: &[TextDirection], measured_width: f32) -> TextContent {
let bounds = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let mut content = TextContent::new(bounds, GrowType::AutoWidth);
for direction in directions {
content.add_paragraph(directed_paragraph(*direction));
}
content.size.width = measured_width;
content.size.height = 20.0;
content
}
#[test]
fn is_rtl_false_when_no_paragraphs() {
let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), GrowType::AutoWidth);
assert!(!content.is_rtl());
}
#[test]
fn is_rtl_true_when_every_paragraph_is_rtl() {
let content = grown_content(&[TextDirection::RTL, TextDirection::RTL], 120.0);
assert!(content.is_rtl());
}
#[test]
fn is_rtl_false_when_directions_are_mixed() {
let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0);
assert!(!content.is_rtl());
}
#[test]
fn is_rtl_false_when_every_paragraph_is_ltr() {
let content = grown_content(&[TextDirection::LTR], 120.0);
assert!(!content.is_rtl());
}
#[test]
fn layout_origin_x_right_anchors_rtl_auto_width() {
let content = grown_content(&[TextDirection::RTL], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
// right edge (160) minus the measured width (120)
assert_eq!(content.layout_origin_x(&selrect, 120.0), 40.0);
}
#[test]
fn layout_origin_x_left_anchors_ltr_auto_width() {
let content = grown_content(&[TextDirection::LTR], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0);
}
#[test]
fn layout_origin_x_left_anchors_mixed_direction_auto_width() {
let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0);
}
#[test]
fn layout_origin_x_ignores_direction_for_fixed_and_auto_height() {
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
for grow_type in [GrowType::Fixed, GrowType::AutoHeight] {
let mut content = grown_content(&[TextDirection::RTL], 120.0);
content.set_grow_type(grow_type);
assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0);
}
}
#[test]
fn layout_origin_x_is_selrect_x_once_the_selrect_is_committed() {
let content = grown_content(&[TextDirection::RTL], 60.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
assert_eq!(content.layout_origin_x(&selrect, 60.0), selrect.x());
}
#[test]
fn content_rect_right_anchors_grown_rtl_auto_width() {
let content = grown_content(&[TextDirection::RTL], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let rect = content.content_rect(&selrect, VerticalAlign::Top);
assert_eq!(rect.x(), 40.0);
assert_eq!(rect.right(), selrect.right());
assert_eq!(rect.width(), 120.0);
assert_eq!(rect.y(), selrect.y());
}
#[test]
fn content_rect_left_anchors_grown_ltr_auto_width() {
let content = grown_content(&[TextDirection::LTR], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let rect = content.content_rect(&selrect, VerticalAlign::Top);
assert_eq!(rect.x(), selrect.x());
assert_eq!(rect.width(), 120.0);
}
#[test]
fn content_rect_unchanged_for_fixed_rtl_content() {
// A mismatch sends `content_rect` into `get_height`, which needs global
// font state tests do not have.
let mut content = grown_content(&[TextDirection::RTL], 60.0);
content.set_grow_type(GrowType::Fixed);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let rect = content.content_rect(&selrect, VerticalAlign::Top);
assert_eq!(rect.x(), selrect.x());
assert_eq!(rect.width(), selrect.width());
}
#[test]
fn cached_paint_anchor_plus_offset_lands_on_the_layout_origin() {
let mut content = grown_content(&[TextDirection::RTL], 120.0);
content.layout_paint_origin = Some(Point::new(100.0, 50.0));
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let anchor = content.cached_layout_paint_anchor(&selrect);
let offset = content.cached_layout_paint_offset(&selrect);
assert_eq!(
anchor.x + offset.x,
content.layout_origin_x(&selrect, 120.0)
);
}
#[test]
fn cached_paint_offset_stays_a_pure_translation_when_the_shape_moves() {
let mut content = grown_content(&[TextDirection::RTL], 120.0);
content.layout_paint_origin = Some(Point::new(100.0, 50.0));
let before = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
let after = Rect::from_xywh(130.0, 70.0, 60.0, 20.0);
let moved_by = Point::new(
content.cached_layout_paint_offset(&after).x
- content.cached_layout_paint_offset(&before).x,
content.cached_layout_paint_offset(&after).y
- content.cached_layout_paint_offset(&before).y,
);
assert_eq!(moved_by, Point::new(30.0, 20.0));
}
#[test]
fn cached_paint_anchor_right_anchors_when_no_origin_was_baked() {
let content = grown_content(&[TextDirection::RTL], 120.0);
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
assert_eq!(content.layout_paint_origin, None);
assert_eq!(content.cached_layout_paint_anchor(&selrect).x, 40.0);
assert_eq!(content.cached_layout_paint_offset(&selrect).x, 0.0);
}
}

View File

@ -5,11 +5,22 @@ HOST="${PENPOT_DB_HOST:-postgres}"
USER="${PENPOT_DB_USER:-penpot}"
PASSWORD="${PENPOT_DB_PASSWORD:-penpot}"
DB="${PENPOT_DB_NAME:-penpot}"
TEST=false
WS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--test)
DB="penpot_test"
TEST=true
shift
;;
--ws)
WS="$2"
shift 2
;;
--ws*)
WS="${1#--ws}"
shift
;;
--host|-h)
@ -30,5 +41,25 @@ while [[ $# -gt 0 ]]; do
esac
done
# --ws selects one instance's isolated backend-test database
# (penpot_test on ws0, penpot_test_wsN on wsN). It only applies together
# with --test because the main database is shared by all instances.
if [[ -n "$WS" ]]; then
if [[ "$WS" =~ ^ws([0-9]+)$ ]]; then
WS="${BASH_REMATCH[1]}"
fi
if [[ ! "$WS" =~ ^[0-9]+$ ]]; then
echo "Invalid --ws value: '$WS' (expected a non-negative integer, e.g. --ws 1)" >&2
exit 1
fi
if [[ "$TEST" != "true" ]]; then
echo "--ws only applies together with --test (the main database is shared by all instances)." >&2
exit 1
fi
if [[ "$WS" != "0" ]]; then
DB="penpot_test_ws${WS}"
fi
fi
export PGPASSWORD="$PASSWORD"
exec pg_dump -h "$HOST" -U "$USER" -d "$DB" --schema-only --no-owner --no-privileges "$@"

View File

@ -5,11 +5,22 @@ HOST="${PENPOT_DB_HOST:-postgres}"
USER="${PENPOT_DB_USER:-penpot}"
PASSWORD="${PENPOT_DB_PASSWORD:-penpot}"
DB="${PENPOT_DB_NAME:-penpot}"
TEST=false
WS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--test)
DB="penpot_test"
TEST=true
shift
;;
--ws)
WS="$2"
shift 2
;;
--ws*)
WS="${1#--ws}"
shift
;;
--host|-h)
@ -30,5 +41,25 @@ while [[ $# -gt 0 ]]; do
esac
done
# --ws selects one instance's isolated backend-test database
# (penpot_test on ws0, penpot_test_wsN on wsN). It only applies together
# with --test because the main database is shared by all instances.
if [[ -n "$WS" ]]; then
if [[ "$WS" =~ ^ws([0-9]+)$ ]]; then
WS="${BASH_REMATCH[1]}"
fi
if [[ ! "$WS" =~ ^[0-9]+$ ]]; then
echo "Invalid --ws value: '$WS' (expected a non-negative integer, e.g. --ws 1)" >&2
exit 1
fi
if [[ "$TEST" != "true" ]]; then
echo "--ws only applies together with --test (the main database is shared by all instances)." >&2
exit 1
fi
if [[ "$WS" != "0" ]]; then
DB="penpot_test_ws${WS}"
fi
fi
export PGPASSWORD="$PASSWORD"
exec psql -h "$HOST" -U "$USER" -d "$DB" "$@"