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

This commit is contained in:
Andrey Antukh 2026-07-09 19:37:16 +02:00
commit f84a8687f6
27 changed files with 461 additions and 488 deletions

View File

@ -35,7 +35,7 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
| Field | Rule |
|-------|------|
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) |
| **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) |
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
@ -114,8 +114,8 @@ Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
| Docs | `IT_kwDOAcyBPM4B_IQz` |
**Map:**
- `bug` label → Bug
- `enhancement` label → Enhancement
- Bug report (steps to reproduce, expected vs. actual) → Bug
- Enhancement / new feature → Enhancement
- Feature/epic → Feature
- Docs → Docs
- None of the above → Task

View File

@ -2,6 +2,20 @@
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
## Target Branch
Auto-detect the base branch with `tools/detect-target-branch`:
```bash
TARGET=$(tools/detect-target-branch)
```
This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails.
## Metadata
Always add the PR to the Main project (`--project "Main"`) unless the user explicitly requests a different project.
## Title Format
PR titles follow commit title conventions:
@ -60,3 +74,24 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- Follow `mem:workflow/creating-commits` for commits
- Run the focused tests/lints appropriate to touched modules.
- Do not force-push during review unless the maintainer workflow explicitly asks for it.
- When the user says the code is already pushed, trust that — do not verify remote branch existence via `git ls-remote` or `git fetch`.
## Creating the PR
```bash
cat > /tmp/pr-body.md << 'PR_BODY'
<body content here>
PR_BODY
TARGET=$(tools/detect-target-branch)
gh pr create \
--repo penpot/penpot \
--base "$TARGET" \
--head <branch> \
--title "<title>" \
--project "Main" \
--body-file /tmp/pr-body.md
rm -f /tmp/pr-body.md
```

View File

@ -331,7 +331,10 @@
This expands to a single SQL statement with placeholders for every
value being inserted. For large data sets, this may exceed the limit
of sql string size and/or number of parameters."
of sql string size and/or number of parameters.
See `insert-many-chunked!` for a safe alternative that automatically
partitions rows to stay within the parameter limit."
[ds table cols rows & {:as opts}]
(let [conn (get-connectable ds)
sql (sql/insert-many table cols rows opts)
@ -341,6 +344,24 @@
opts (update opts :return-keys boolean)]
(jdbc/execute! conn sql opts)))
(def ^:private default-max-params
"PostgreSQL PreparedStatement parameter limit."
65535)
(defn insert-many-chunked!
"Like `insert-many!` but partitions rows into chunks that stay within
PostgreSQL's 65,535 PreparedStatement parameter limit.
The chunk size is computed as `floor(max-params / num-columns)`,
so callers do not need to calculate it. All chunks execute within
the same transaction when called inside `tx-run!`."
[ds table cols rows & {:keys [max-params] :as opts
:or {max-params default-max-params}}]
(let [chunk-size (quot max-params (count cols))
opts (dissoc opts :max-params)]
(doseq [chunk (partition-all chunk-size rows)]
(apply insert-many! ds table cols chunk (mapcat identity opts)))))
(defn update!
"A helper that build an UPDATE SQL statement and executes it.

View File

@ -65,7 +65,6 @@
(assert (every? string? cmd) "the command should be a vector of strings")
(let [executor (::wrk/executor system)
_ (assert (some? executor) "executor is required, check ::wrk/executor")
full-cmd (cond->> cmd
(seq prlimit)
(into (prlimit-cmd prlimit)))
@ -74,6 +73,9 @@
_ (reduce-kv set-env env-map env)
process (.start builder)]
(when-not executor
(throw (IllegalArgumentException. "invalid system/cfg provided, missing ::wrk/executor")))
(if in
(px/run! executor
(fn []

View File

@ -8,12 +8,17 @@
(:require
[app.common.exceptions :as ex]
[app.util.shell :as shell]
[app.worker :as-alias wrk]
[clojure.string :as str]
[clojure.test :as t]))
[clojure.test :as t]
[promesa.exec :as px]))
(def ^:private system
{::wrk/executor (px/cached-executor)})
(t/deftest exec-normal-completes
(t/testing "normal process completes within timeout"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["echo" "hello"]
:timeout 10)]
(t/is (= 0 (:exit result)))
@ -21,7 +26,7 @@
(t/deftest exec-captures-stderr
(t/testing "stderr is captured separately"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "echo out; echo err >&2"]
:timeout 10)]
(t/is (= 0 (:exit result)))
@ -30,14 +35,14 @@
(t/deftest exec-non-zero-exit
(t/testing "non-zero exit code is captured"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "exit 42"]
:timeout 10)]
(t/is (= 42 (:exit result))))))
(t/deftest exec-with-env
(t/testing "environment variables are passed to the process"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "echo $MY_VAR"]
:env {"MY_VAR" "test-value"}
:timeout 10)]
@ -46,7 +51,7 @@
(t/deftest exec-with-input
(t/testing "stdin input is passed to the process"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["cat"]
:in "hello from stdin"
:timeout 10)]
@ -57,7 +62,7 @@
(t/testing "process that exceeds timeout is killed and raises exception"
(let [start (System/currentTimeMillis)]
(try
(shell/exec! {}
(shell/exec! system
:cmd ["sleep" "60"]
:timeout 1)
(t/is false "should have thrown")
@ -72,14 +77,14 @@
(t/deftest exec-no-timeout-waits
(t/testing "without timeout, process runs to completion"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["sleep" "0.1"]
:timeout nil)]
(t/is (= 0 (:exit result))))))
(t/deftest exec-prlimit-normal
(t/testing "normal process completes within prlimit"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["echo" "hello"]
:prlimit {:mem 256 :cpu 10}
:timeout 10)]
@ -88,7 +93,7 @@
(t/deftest exec-prlimit-cpu
(t/testing "process exceeding CPU limit is killed"
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["bash" "-c" "while true; do :; done"]
:prlimit {:cpu 2}
:timeout 10)]
@ -98,7 +103,7 @@
(t/testing "process exceeding memory limit is killed"
;; Use python3 to allocate more memory than the limit allows.
;; This test requires python3 to be available in the environment.
(let [result (shell/exec! {}
(let [result (shell/exec! system
:cmd ["python3" "-c"
"import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"]
:prlimit {:mem 256}

View File

@ -500,9 +500,9 @@
shape-transform (:transform shape)
shape-transform-inv (:transform-inverse shape)
shape-center (gco/shape->center shape)
{sr-width :width sr-height :height} (:selrect shape)
{sr-width :width sr-height :height} (safe-size-rect shape)
origin (cond-> (gpt/point (:selrect shape))
origin (cond-> (gpt/point (safe-size-rect shape))
(some? shape-transform)
(gmt/transform-point-center shape-center shape-transform))

View File

@ -392,7 +392,9 @@
([shape]
(convert-to-path shape {}))
([shape objects]
(-> (stp/convert-to-path shape objects)
(update :content impl/path-data))))
(let [shape' (stp/convert-to-path shape objects)]
(if (identical? shape shape')
shape'
(update shape' :content impl/path-data)))))
(dm/export impl/decode-segments)

View File

@ -657,3 +657,35 @@
mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45)
result (ctm/apply-structure-modifiers shape mods)]
(t/is (mth/close? 45.0 (:rotation result))))))
;; ─── change-orientation-modifiers — degenerate selrect ────────────────────────
(defn- make-degenerate-shape
"Build a shape whose selrect has zero width/height, simulating a shape
decoded from the server via map->Rect (bypasses make-rect's 0.01 floor)."
[x y selrect-width selrect-height]
(let [shape (make-shape x y 100 50)]
(assoc shape :selrect (grc/map->Rect {:x x :y y
:width selrect-width
:height selrect-height
:x1 x :y1 y
:x2 (+ x selrect-width)
:y2 (+ y selrect-height)}))))
(t/deftest change-orientation-zero-width-selrect-does-not-throw
(t/testing "orientation change on a shape with zero selrect width does not throw"
(let [shape (make-degenerate-shape 0 0 0 50)
mods (ctm/change-orientation-modifiers shape :horiz)]
(t/is (some? mods)))))
(t/deftest change-orientation-zero-height-selrect-does-not-throw
(t/testing "orientation change on a shape with zero selrect height does not throw"
(let [shape (make-degenerate-shape 0 0 100 0)
mods (ctm/change-orientation-modifiers shape :vert)]
(t/is (some? mods)))))
(t/deftest change-orientation-zero-width-and-height-selrect-does-not-throw
(t/testing "orientation change on a fully degenerate selrect does not throw"
(let [shape (make-degenerate-shape 0 0 0 0)
mods (ctm/change-orientation-modifiers shape :horiz)]
(t/is (some? mods)))))

View File

@ -1370,6 +1370,15 @@
;; A path shape stays a path shape unchanged
(t/is (= :path (:type result)))))
(t/deftest shape-to-path-svg-raw-does-not-throw
(let [shape {:type :svg-raw :x 0.0 :y 0.0 :width 100.0 :height 50.0
:selrect (make-selrect 0.0 0.0 100.0 50.0)
:content {:tag :text :attrs {:style {}}
:content [{:tag :tspan :attrs {} :content ["x"]}]}}
result (path/convert-to-path shape {})]
(t/is (= :svg-raw (:type result)))
(t/is (some? (:content result)))))
(t/deftest shape-to-path-rect-with-radius
(let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0
:r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0

View File

@ -66,7 +66,7 @@ RUN set -eux; \
FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.17.13
ENV OPENCODE_VERSION=1.17.16
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \

View File

@ -8,7 +8,7 @@
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {
"draft-js": "penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0",
"draft-js": "penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35",
"immutable": "^5.1.9"
},
"peerDependencies": {

View File

@ -1,449 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
draft-js:
specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0
version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
immutable:
specifier: ^5.1.4
version: 5.1.4
react:
specifier: '>=0.17.0'
version: 19.2.3
react-dom:
specifier: '>=0.17.0'
version: 19.2.3(react@19.2.3)
devDependencies:
esbuild:
specifier: ^0.27.2
version: 0.27.2
packages:
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.2':
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.2':
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.2':
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.2':
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.2':
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.2':
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.2':
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.2':
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.2':
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.2':
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.2':
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.2':
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.2':
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.2':
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.2':
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.2':
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.2':
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.2':
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.2':
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.2':
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.2':
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.2':
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.2':
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.2':
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.2':
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0:
resolution: {tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0}
version: 0.11.7
peerDependencies:
react: '>=0.14.0'
react-dom: '>=0.14.0'
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
hasBin: true
fbjs-css-vars@1.0.2:
resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==}
fbjs@3.0.5:
resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==}
immutable@3.7.6:
resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==}
engines: {node: '>=0.8.0'}
immutable@5.1.4:
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
react: ^19.2.3
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
ua-parser-js@1.0.41:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
snapshots:
'@esbuild/aix-ppc64@0.27.2':
optional: true
'@esbuild/android-arm64@0.27.2':
optional: true
'@esbuild/android-arm@0.27.2':
optional: true
'@esbuild/android-x64@0.27.2':
optional: true
'@esbuild/darwin-arm64@0.27.2':
optional: true
'@esbuild/darwin-x64@0.27.2':
optional: true
'@esbuild/freebsd-arm64@0.27.2':
optional: true
'@esbuild/freebsd-x64@0.27.2':
optional: true
'@esbuild/linux-arm64@0.27.2':
optional: true
'@esbuild/linux-arm@0.27.2':
optional: true
'@esbuild/linux-ia32@0.27.2':
optional: true
'@esbuild/linux-loong64@0.27.2':
optional: true
'@esbuild/linux-mips64el@0.27.2':
optional: true
'@esbuild/linux-ppc64@0.27.2':
optional: true
'@esbuild/linux-riscv64@0.27.2':
optional: true
'@esbuild/linux-s390x@0.27.2':
optional: true
'@esbuild/linux-x64@0.27.2':
optional: true
'@esbuild/netbsd-arm64@0.27.2':
optional: true
'@esbuild/netbsd-x64@0.27.2':
optional: true
'@esbuild/openbsd-arm64@0.27.2':
optional: true
'@esbuild/openbsd-x64@0.27.2':
optional: true
'@esbuild/openharmony-arm64@0.27.2':
optional: true
'@esbuild/sunos-x64@0.27.2':
optional: true
'@esbuild/win32-arm64@0.27.2':
optional: true
'@esbuild/win32-ia32@0.27.2':
optional: true
'@esbuild/win32-x64@0.27.2':
optional: true
asap@2.0.6: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
fbjs: 3.0.5
immutable: 3.7.6
object-assign: 4.1.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
transitivePeerDependencies:
- encoding
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
'@esbuild/android-arm': 0.27.2
'@esbuild/android-arm64': 0.27.2
'@esbuild/android-x64': 0.27.2
'@esbuild/darwin-arm64': 0.27.2
'@esbuild/darwin-x64': 0.27.2
'@esbuild/freebsd-arm64': 0.27.2
'@esbuild/freebsd-x64': 0.27.2
'@esbuild/linux-arm': 0.27.2
'@esbuild/linux-arm64': 0.27.2
'@esbuild/linux-ia32': 0.27.2
'@esbuild/linux-loong64': 0.27.2
'@esbuild/linux-mips64el': 0.27.2
'@esbuild/linux-ppc64': 0.27.2
'@esbuild/linux-riscv64': 0.27.2
'@esbuild/linux-s390x': 0.27.2
'@esbuild/linux-x64': 0.27.2
'@esbuild/netbsd-arm64': 0.27.2
'@esbuild/netbsd-x64': 0.27.2
'@esbuild/openbsd-arm64': 0.27.2
'@esbuild/openbsd-x64': 0.27.2
'@esbuild/openharmony-arm64': 0.27.2
'@esbuild/sunos-x64': 0.27.2
'@esbuild/win32-arm64': 0.27.2
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
fbjs-css-vars@1.0.2: {}
fbjs@3.0.5:
dependencies:
cross-fetch: 3.2.0
fbjs-css-vars: 1.0.2
loose-envify: 1.4.0
object-assign: 4.1.1
promise: 7.3.1
setimmediate: 1.0.5
ua-parser-js: 1.0.41
transitivePeerDependencies:
- encoding
immutable@3.7.6: {}
immutable@5.1.4: {}
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
object-assign@4.1.1: {}
promise@7.3.1:
dependencies:
asap: 2.0.6
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
scheduler: 0.27.0
react@19.2.3: {}
scheduler@0.27.0: {}
setimmediate@1.0.5: {}
tr46@0.0.3: {}
ua-parser-js@1.0.41: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1

View File

@ -263,8 +263,8 @@ importers:
packages/draft-js:
dependencies:
draft-js:
specifier: penpot/draft-js.git#4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0
version: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35
version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
immutable:
specifier: ^5.1.9
version: 5.1.9
@ -2886,8 +2886,8 @@ packages:
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
engines: {node: '>=20.19.0'}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0:
resolution: {gitHosted: true, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0}
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35:
resolution: {gitHosted: true, integrity: sha512-IN2r8sw36jcH32WPP9eBFXNglirhkFIsqgoKzwwSDxiCbeipumeKXjYB3vw82CxYRwKc+oRTMcZ5jNnCWnmnBw==, tarball: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35}
version: 0.11.7
peerDependencies:
react: '>=0.14.0'
@ -8117,7 +8117,7 @@ snapshots:
domelementtype: 3.0.0
domhandler: 6.0.1
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/4a99b2a6020b2af97f6dc5fa1b4275ec16b559a0(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
fbjs: 3.0.5(encoding@0.1.13)
immutable: 3.8.3

View File

@ -24,7 +24,6 @@
[app.main.data.workspace.drawing :as dwd]
[app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.layout :as dwlo]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.viewport-wasm :as dwvw]
[app.main.data.workspace.zoom :as dwz]
[app.main.repo :as rp]
@ -72,7 +71,7 @@
(rx/take-until stopper-s))))))
(defn- handle-interrupt
(defn handle-interrupt
[]
(ptk/reify ::handle-interrupt
ptk/WatchEvent
@ -87,8 +86,7 @@
;; tool is active. When comments are merely visible during design,
;; `select-shape` emits `:interrupt` and this would otherwise wipe
;; the freshly selected shape, breaking click selection.
comments-mode? (rx/of (dwe/clear-edition-mode)
(dws/deselect-all true))
comments-mode? (rx/of (dwe/clear-edition-mode))
:else (rx/empty))))))
;; Event responsible of the what should be executed when user clicked

View File

@ -256,8 +256,8 @@
(defn assoc-position-data
[shape position-data old-shape]
(let [deltav (gpt/to-vec (gpt/point (:selrect old-shape))
(gpt/point (:selrect shape)))
(let [deltav (gpt/to-vec (gpt/point (ctm/safe-size-rect old-shape))
(gpt/point (ctm/safe-size-rect shape)))
position-data
(-> position-data
(gsh/move-position-data deltav))]

View File

@ -35,8 +35,8 @@
(if-let [modifiers (:modifiers shape)]
(let [shape' (gsh/transform-shape shape modifiers)
old-sr (dm/get-prop shape :selrect)
new-sr (dm/get-prop shape' :selrect)
old-sr (ctm/safe-size-rect shape)
new-sr (ctm/safe-size-rect shape')
;; We need to remove the movement because the dynamic modifiers will have move it
deltav (gpt/to-vec (gpt/point new-sr)

View File

@ -522,7 +522,7 @@
[:*
[:div {:class (stl/css :variant-property-list)}
(for [[pos prop] (map-indexed vector props-first)]
(let [mixed-value? (not-every? #(= (:value prop) (:value (nth % pos))) properties)
(let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties)
options (get-options (:name prop))
boolean-pair (ctv/find-boolean-pair (mapv :id options))
options (cond-> options

View File

@ -1059,6 +1059,9 @@
:setPluginData
(fn [key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/not-valid plugin-id :setPluginData-non-local-library file-id)
(not (string? key))
(u/not-valid plugin-id :setPluginData-key key)
@ -1092,6 +1095,9 @@
:setSharedPluginData
(fn [namespace key value]
(cond
(not= file-id (:current-file-id @st/state))
(u/not-valid plugin-id :setSharedPluginData-non-local-library file-id)
(not (string? namespace))
(u/not-valid plugin-id :setSharedPluginData-namespace namespace)

View File

@ -272,13 +272,13 @@
(st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value))))
:getSharedPluginDataKeys
(fn [self namespace]
(fn [namespace]
(cond
(not (string? namespace))
(u/not-valid plugin-id :page-plugin-data-namespace namespace)
:else
(let [page (u/proxy->page self)]
(let [page (u/locate-page file-id id)]
(apply array (keys (dm/get-in page [:plugin-data (keyword "shared" namespace)]))))))
:openPage

View File

@ -0,0 +1,84 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.data.workspace-comments-test
(:require
[app.main.data.comments :as dcmt]
[app.main.data.workspace.comments :as dwcm]
[app.main.data.workspace.edition :as dwe]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[potok.v2.core :as ptk]))
(t/deftest test-handle-interrupt-draft
(t/async
done
(let [event (dwcm/handle-interrupt)
state {:comments-local {:draft {:id "draft-id"}}}
result (ptk/watch event state (rx/empty))]
(->> result
(rx/subs!
(fn [evt]
(t/is (= ::dcmt/close-comment-thread (ptk/type evt))))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(done)))))))
(t/deftest test-handle-interrupt-open
(t/async
done
(let [event (dwcm/handle-interrupt)
state {:comments-local {:open {:id "thread-id"}}}
result (ptk/watch event state (rx/empty))]
(->> result
(rx/subs!
(fn [evt]
(t/is (= ::dcmt/close-comment-thread (ptk/type evt))))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(done)))))))
(t/deftest test-handle-interrupt-comments-mode
(t/async
done
(let [event (dwcm/handle-interrupt)
state {:workspace-drawing {:tool :comments}}
result (ptk/watch event state (rx/empty))]
(->> result
(rx/subs!
(fn [evt]
(t/is (= ::dwe/clear-edition-mode (ptk/type evt))))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(done)))))))
(t/deftest test-handle-interrupt-noop
(t/async
done
(let [event (dwcm/handle-interrupt)
state {}
result (ptk/watch event state (rx/empty))
emitted? (atom false)]
(->> result
(rx/subs!
(fn [_]
(reset! emitted? true))
(fn [err]
(done)
(js/console.error err)
(t/do-report {:type :error :message "Stream error" :actual err}))
(fn [_]
(t/is (false? @emitted?) "should not emit any events")
(done)))))))

View File

@ -9,9 +9,11 @@
[app.common.geom.rect :as grc]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.shapes :as cths]
[app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.main.data.workspace.texts :as dwt]
[app.main.ui.workspace.shapes.text.viewport-texts-html :as vth]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]))
@ -374,3 +376,34 @@
"exactly one typography was added")
(t/is (= "0.1" (:letter-spacing (first typographies)))
"float letter-spacing is normalised to 2-decimal string")))))))
;; ---------------------------------------------------------------------------
;; Tests: fix-position with degenerate selrect
;; ---------------------------------------------------------------------------
(t/deftest fix-position-zero-width-selrect-does-not-throw
(t/testing "fix-position on a shape with zero selrect width does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50)
modifiers (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true})
shape' (assoc shape :modifiers modifiers)
result (vth/fix-position shape')]
(t/is (some? result))
(t/is (some? (:selrect result))))))
(t/deftest fix-position-zero-height-selrect-does-not-throw
(t/testing "fix-position on a shape with zero selrect height does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0)
modifiers (ctm/change-dimensions-modifiers shape :height 80 {:ignore-lock? true})
shape' (assoc shape :modifiers modifiers)
result (vth/fix-position shape')]
(t/is (some? result))
(t/is (some? (:selrect result))))))
(t/deftest fix-position-zero-width-and-height-selrect-does-not-throw
(t/testing "fix-position on a fully degenerate selrect does not throw"
(let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
modifiers (ctm/change-dimensions-modifiers shape :width 150 {:ignore-lock? true})
shape' (assoc shape :modifiers modifiers)
result (vth/fix-position shape')]
(t/is (some? result))
(t/is (some? (:selrect result))))))

View File

@ -12,6 +12,7 @@
[frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test]
[frontend-tests.data.workspace-colors-test]
[frontend-tests.data.workspace-comments-test]
[frontend-tests.data.workspace-interactions-test]
[frontend-tests.data.workspace-mcp-test]
[frontend-tests.data.workspace-media-test]
@ -83,6 +84,7 @@
'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test
'frontend-tests.data.workspace-colors-test
'frontend-tests.data.workspace-comments-test
'frontend-tests.data.workspace-interactions-test
'frontend-tests.data.workspace-mcp-test
'frontend-tests.data.workspace-media-test

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"

View File

@ -52,6 +52,8 @@
- **plugins-runtime**: `penpot.openPage()` (and `Page.openPage()`) now resolves immediately when the target page is already active, instead of waiting forever for a page-initialization event that never fires.
- **plugins-runtime**: `Shape.shadows`, `Shape.exports` and grid `rows`/`columns` now return live proxies, so writing a member on a returned shadow/export/track (e.g. `shape.shadows[0].blur = 7`) persists to the shape instead of mutating a detached snapshot that was silently discarded. The shadow `color` remains a plain snapshot (reconfigure it by assigning `shadow.color`).
- **plugins-runtime**: Setting a variant component's `path` now renames the whole variant (its container and every main instance), like the `name` setter already did, instead of renaming only the component and leaving the file referentially inconsistent (which the backend rejected on save with a `variant-component-bad-name` error).
- **plugins-runtime**: `Page.getSharedPluginDataKeys(namespace)` now works instead of always raising a namespace validation error: the implementation expected a spurious leading argument, so the caller's `namespace` was read as a missing second argument.
- **plugins-runtime**: Storing plugin data on a connected (non-local) shared library is now consistently rejected with a `setPluginData-non-local-library` error on the `Library` object as well as its assets (colors, typographies, components). Previously the `Library` object accepted the write and applied it optimistically, but plugin data is not part of library synchronization and the change only persists when the caller can edit the library file — on a read-only shared library it failed silently and was lost on reload. Plugin data can only be stored on the file currently being edited.
## 1.4.2 (2026-01-21)

View File

@ -34,6 +34,24 @@ describe('Library', () => {
expect(Array.isArray(ctx.penpot.library.connected)).toBe(true);
});
// The Library object itself carries plugin data (it extends PluginData),
// stored on the underlying file. Exercised on the local library; the
// connected-library case (writing to a shared library's file/assets) shares
// the same code path but can't be fixtured here (connecting a library hangs).
test('local library stores plugin data', (ctx) => {
const lib = ctx.penpot.library.local;
lib.setPluginData('k', 'v');
expect(lib.getPluginData('k')).toBe('v');
expect(lib.getPluginDataKeys()).toContain('k');
});
test('local library stores shared plugin data', (ctx) => {
const lib = ctx.penpot.library.local;
lib.setSharedPluginData('ns', 'k', 'v');
expect(lib.getSharedPluginData('ns', 'k')).toBe('v');
expect(lib.getSharedPluginDataKeys('ns')).toContain('k');
});
test('library elements expose a libraryId', (ctx) => {
const color = ctx.penpot.library.local.createColor();
expect(typeof color.libraryId).toBe('string');
@ -136,6 +154,13 @@ describe('Library', () => {
expect(color.getPluginData('k')).toBe('v');
expect(color.getPluginDataKeys()).toContain('k');
});
test('color shared plugin data round-trips', (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.setSharedPluginData('ns', 'k', 'v');
expect(color.getSharedPluginData('ns', 'k')).toBe('v');
expect(color.getSharedPluginDataKeys('ns')).toContain('k');
});
});
describe('Typographies', () => {
@ -170,6 +195,13 @@ describe('Library', () => {
expect(typo.getPluginDataKeys()).toContain('k');
});
test('typography shared plugin data round-trips', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
typo.setSharedPluginData('ns', 'k', 'v');
expect(typo.getSharedPluginData('ns', 'k')).toBe('v');
expect(typo.getSharedPluginDataKeys('ns')).toContain('k');
});
test('typography fontFamily and fontId round-trip', (ctx) => {
const typo = ctx.penpot.library.local.createTypography();
expect(typeof typo.fontFamily).toBe('string');
@ -256,6 +288,15 @@ describe('Library', () => {
expect(comp.getPluginDataKeys()).toContain('k');
});
test('component shared plugin data round-trips', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
const comp = ctx.penpot.library.local.createComponent([rect]);
comp.setSharedPluginData('ns', 'k', 'v');
expect(comp.getSharedPluginData('ns', 'k')).toBe('v');
expect(comp.getSharedPluginDataKeys('ns')).toContain('k');
});
test('component instance and mainInstance return shapes', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);

View File

@ -26,6 +26,41 @@ describe('Plugin data', () => {
if (file) {
file.setPluginData('fileKey', 'fileValue');
expect(file.getPluginData('fileKey')).toBe('fileValue');
expect(file.getPluginDataKeys()).toContain('fileKey');
}
});
test('shared plugin data round-trips on the file', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
file.setSharedPluginData('ns', 'fileShared', 'fileSharedValue');
expect(file.getSharedPluginData('ns', 'fileShared')).toBe(
'fileSharedValue',
);
expect(file.getSharedPluginDataKeys('ns')).toContain('fileShared');
}
});
test('plugin data round-trips on a page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
page.setPluginData('pageKey', 'pageValue');
expect(page.getPluginData('pageKey')).toBe('pageValue');
expect(page.getPluginDataKeys()).toContain('pageKey');
}
});
test('shared plugin data round-trips on a page', (ctx) => {
const page = ctx.penpot.currentPage;
expect(page).not.toBeNull();
if (page) {
page.setSharedPluginData('ns', 'pageShared', 'pageSharedValue');
expect(page.getSharedPluginData('ns', 'pageShared')).toBe(
'pageSharedValue',
);
expect(page.getSharedPluginDataKeys('ns')).toContain('pageShared');
}
});
@ -45,6 +80,47 @@ describe('Plugin data', () => {
expect(rect.getPluginDataKeys()).toContain('');
});
// Keys are opaque strings: no case normalization is applied. A camelCase key
// and its kebab-case spelling are distinct entries that each round-trip
// independently. Pins that behaviour so a future key-normalization regression
// (reported as camelCase keys "behaving incorrectly") would be caught here.
test('camelCase and kebab-case keys are distinct and both round-trip', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setPluginData('myKey', 'camel');
rect.setPluginData('my-key', 'kebab');
expect(rect.getPluginData('myKey')).toBe('camel');
expect(rect.getPluginData('my-key')).toBe('kebab');
const keys = rect.getPluginDataKeys();
expect(keys).toContain('myKey');
expect(keys).toContain('my-key');
});
// Same guarantee at the file-data storage location (a distinct code path from
// shape/page objects), covering a camelCase key on the file itself.
test('a camelCase key round-trips on the file', (ctx) => {
const file = ctx.penpot.currentFile;
expect(file).not.toBeNull();
if (file) {
file.setPluginData('camelCaseFileKey', 'value');
expect(file.getPluginData('camelCaseFileKey')).toBe('value');
expect(file.getPluginDataKeys()).toContain('camelCaseFileKey');
}
});
// camelCase is likewise preserved in the shared namespace and the shared key.
test('camelCase shared namespace and key round-trip', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);
rect.setSharedPluginData('myNamespace', 'myKey', 'camel');
rect.setSharedPluginData('myNamespace', 'my-key', 'kebab');
expect(rect.getSharedPluginData('myNamespace', 'myKey')).toBe('camel');
expect(rect.getSharedPluginData('myNamespace', 'my-key')).toBe('kebab');
const keys = rect.getSharedPluginDataKeys('myNamespace');
expect(keys).toContain('myKey');
expect(keys).toContain('my-key');
});
test('setPluginData with a non-string value throws', (ctx) => {
const rect = ctx.penpot.createRectangle();
ctx.board.appendChild(rect);

74
tools/detect-target-branch Executable file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
# detect-target-branch
#
# Determines the target branch for a PR of the current branch.
#
# Uses git name-rev to find the nearest ref (branch/tag) that is an ancestor of
# HEAD, falling back to a graph walk if name-rev returns the current branch.
#
# Pure local — no remote, no SSH, no network. Works even when the current
# branch shares its tip commit with the target (not yet diverged).
#
# Usage:
# tools/detect-target-branch # print branch name
# tools/detect-target-branch --verbose # print "branch~N"
#
# Exit status:
# 0 — target found
# 1 — no target found (orphan commit, no other branches)
verbose=false
for arg do
case "$arg" in
--verbose|-v) verbose=true ;;
--name-only) ;;
--help|-h)
sed -n '/^#/,/^$/p' "$0" | sed 's/^# //; s/^#$//'
exit 0
;;
*)
echo "Unknown option: $arg" >&2
echo "Usage: $(basename "$0") [--verbose|-v] [--name-only] [--help|-h]" >&2
exit 1
;;
esac
done
current=$(git rev-parse --abbrev-ref HEAD)
# Use name-rev to find the nearest ref — it has smart tie-breaking
raw=$(git name-rev --name-only HEAD 2>/dev/null)
target=${raw%%~[0-9]*} # strip ~N distance suffix
# If name-rev returned the current branch itself, walk backwards
if [ "$target" = "$current" ] || [ -z "$target" ]; then
target=$(git rev-list HEAD | while read commit; do
branch=$(git branch --points-at="$commit" --format='%(refname:short)' 2>/dev/null \
| grep -v "^$current$" \
| head -1 || true)
if [ -n "$branch" ]; then
echo "$branch"
break
fi
done || true)
if [ -z "$target" ]; then
echo "error: unable to determine target branch" >&2
exit 1
fi
fi
if [ "$verbose" = true ]; then
target_commit=$(git rev-parse "$target" 2>/dev/null || true)
if [ -n "$target_commit" ]; then
distance=$(git rev-list --count HEAD ^"$target_commit" 2>/dev/null || echo "0")
echo "${target}~${distance}"
else
echo "${target}~?"
fi
else
echo "$target"
fi