diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.opencode/skills/nrepl-eval/SKILL.md index 2cf44d88c7..b4b3d76d7f 100644 --- a/.opencode/skills/nrepl-eval/SKILL.md +++ b/.opencode/skills/nrepl-eval/SKILL.md @@ -6,26 +6,20 @@ description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-ev # nREPL Eval Evaluate Clojure (or ClojureScript) code via a running nREPL server using -`tools/nrepl-eval.mjs` — a standalone CLI application. +`tools/nrepl-eval.mjs`. -Session state (defs, in-ns, etc.) persists across invocations via a stored -session ID, so you can build up state incrementally. +Full documentation: `mem:tools/nrepl-eval` (file: `.serena/memories/tools/nrepl-eval.md`) -## Usage +## Quick Reference -```bash -node tools/nrepl-eval.mjs [options] [] -``` - -The tool is also executable directly: ```bash ./tools/nrepl-eval.mjs [options] [] ``` -## Options - | Flag | Description | Default | |------|-------------|---------| +| `--backend` | Connect to backend nREPL (port 6064) | — | +| `--frontend` | Connect to frontend nREPL (port 3447) | — | | `-p, --port PORT` | nREPL server port | `6064` | | `-H, --host HOST` | nREPL server host | `127.0.0.1` | | `-t, --timeout MS` | Timeout in milliseconds | `120000` | @@ -33,88 +27,11 @@ The tool is also executable directly: | `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — | | `-h, --help` | Show help message | — | -## When to Use +## Examples -Use this tool when you need to: - -1. **Evaluate Clojure code** during development — test functions, inspect - state, or run experiments against a running Clojure process. -2. **Verify that edited files compile** — require namespaces with `:reload` - to pick up changes. -3. **Inspect the last exception** after a failed evaluation — use `-e` to - print the error stored in `*e`. - -## Workflow - -### 1. Session management - -Sessions are persisted to `/tmp/penpot-nrepl-session--`. State -carries across calls automatically: - -```bash -./tools/nrepl-eval.mjs '(def x 42)' -./tools/nrepl-eval.mjs 'x' -# => 42 -``` - -Reset the session to start fresh: - -```bash -./tools/nrepl-eval.mjs --reset-session '(def x 0)' -``` - -### 2. Evaluate code - -**Single expression (inline) — uses default port 6064:** ```bash ./tools/nrepl-eval.mjs '(+ 1 2 3)' -``` - -**Multiple expressions via heredoc (recommended — avoids escaping issues):** -```bash -./tools/nrepl-eval.mjs <<'EOF' -(def x 10) -(+ x 20) -EOF -``` - -**Override with a different port:** -```bash -./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)' -``` - -### 3. Inspect last exception - -After code throws an error, retrieve the full exception details: - -```bash +./tools/nrepl-eval.mjs --backend '(+ 1 2 3)' +./tools/nrepl-eval.mjs --frontend '(js/alert "hi")' ./tools/nrepl-eval.mjs -e ``` - -## Common Patterns - -**Require a namespace with reload:** -```bash -./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)" -``` - -**Test a function:** -```bash -./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)" -``` - -**Long-running operation with custom timeout:** -```bash -./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)" -``` - -## Key Principles - -- **Default port is 6064** — just pass code directly, no `-p` needed when - your nREPL server is on 6064. Use `-p ` for a different port. -- **Always use `:reload`** when requiring namespaces to pick up file changes. -- **Session is reused** across invocations — defs, in-ns, and var bindings - persist. Use `--reset-session` to clear. -- **Do not start any server** — the tool connects to an existing nREPL - server, it is not the agent's responsibility to start the nREPL server - (assume the server is already running on the specified port). diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index d815f69070..f416b78cf8 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -106,3 +106,4 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. * **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. * **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace. * **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas. +* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`. diff --git a/.serena/memories/common/core.md b/.serena/memories/common/core.md index 474fb8d87e..23fbe20198 100644 --- a/.serena/memories/common/core.md +++ b/.serena/memories/common/core.md @@ -49,6 +49,7 @@ Components, variants, and debugging: Text and tests: - Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`. - Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`. +- Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:common/testing-principles`. ## Areas without focused memories diff --git a/.serena/memories/common/testing-principles.md b/.serena/memories/common/testing-principles.md new file mode 100644 index 0000000000..860852393b --- /dev/null +++ b/.serena/memories/common/testing-principles.md @@ -0,0 +1,47 @@ +# Testing Principles (Cross-Cutting) + +Shared testing principles for all modules (backend, frontend, common, render-wasm, plugins). Module-specific commands and helpers are in each module's own testing memory. + +## Core Principles + +- **Test State, Not Interactions** — assert on outcomes, not method calls; survives refactoring +- **DAMP over DRY** — tests are specifications; duplication OK if each test is self-contained and readable +- **Prefer Real Implementations** — hierarchy: Real > Fake > Stub > Mock; mock only at boundaries (network, filesystem, email) +- **Arrange-Act-Assert** — every test: setup / action / verify +- **One Assertion Per Concept** — each test verifies one behavior; split compound assertions +- **Descriptive Test Names** — names read like specifications + +## Anti-Patterns + +- Testing implementation details → breaks on refactor +- Flaky tests (timing, order-dependent) → erode trust +- Mocking everything → tests pass, production breaks +- No test isolation → pass individually, fail together +- Testing framework code → waste of time +- Snapshot abuse → nobody reviews, break on any change + +## Verification Checklist + +After completing any implementation: +- Every new behavior has a corresponding test +- All tests pass for touched modules +- Bug fixes include a reproduction test that failed before the fix +- Test names describe the behavior being verified +- No tests were skipped or disabled + +## Red Flags + +- Writing code without corresponding tests +- Tests that pass on the first run (may not be testing what you think) +- Bug fixes without reproduction tests +- Tests that test framework behavior instead of app behavior +- Skipping tests to make the suite pass + +## Rationalizations + +- "I'll write tests after" → you won't; they'll test implementation not behavior +- "Too simple to test" → simple gets complicated; test documents expected behavior +- "Tests slow me down" → they speed up every future change +- "I tested manually" → manual testing doesn't persist +- "Code is self-explanatory" → tests ARE the specification +- "Just a prototype" → prototypes become production; test debt accumulates diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index d0f83b656b..1027829bcf 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -48,13 +48,24 @@ module. You can read it from `mem:/core` When working on devenv startup, compose layout, instance config (`defaults.env`), tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s `*-devenv` commands, read `mem:devenv/core`. -- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval), - `paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), - `psql` / `db-schema` (PostgreSQL client and schema dump wrappers, see `mem:tools/psql`), and - `taiga.py` / `gh.py` (issue management helpers). - `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it. - `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it. +# Dev tools + +- `tools/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL. + Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases. + See `mem:tools/nrepl-eval`. +- `tools/paren-repair.bb` — Fix mismatched delimiters in Clojure/CLJS files + and reformat with cljfmt. Run before lint checks when LLM edits break parens. + See `mem:tools/paren-repair`. +- `tools/psql` — PostgreSQL client wrapper with devenv defaults. + Companion: `tools/db-schema` for DDL dumps. See `mem:tools/psql`. +- `tools/taiga.py` — Fetch public issues, user stories, and tasks from the + Penpot Taiga project without authentication. See `mem:tools/taiga`. +- `tools/gh.py` — GitHub operations helper: list milestone issues, fetch PR + details, compare against CHANGES.md. Requires `gh` CLI. See `mem:tools/gh`. + # Dependency graph `frontend -> common`, `backend -> common`, `exporter -> common`, and `frontend -> render-wasm`. Changes in `common` can diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md index 7edf80bbe1..b890de6770 100644 --- a/.serena/memories/frontend/core.md +++ b/.serena/memories/frontend/core.md @@ -52,6 +52,7 @@ Diagnostics and validation: - Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`. - Runtime crash recovery: `mem:frontend/handling-crashes`. - Tests and live verification: `mem:frontend/testing`. +- Cross-cutting testing principles and anti-patterns: `mem:common/testing-principles`. - Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`. ## Areas without focused memories diff --git a/.serena/memories/tools/gh.md b/.serena/memories/tools/gh.md new file mode 100644 index 0000000000..3f74b82b66 --- /dev/null +++ b/.serena/memories/tools/gh.md @@ -0,0 +1,80 @@ +# GitHub operations helper + +`tools/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub +repository via GraphQL and REST APIs through the authenticated `gh` CLI. + +## When to use + +- Listing issues in a milestone (for changelog generation). +- Finding issues with no milestone. +- Fetching PR details by number or by milestone. +- Comparing milestone issues against CHANGES.md to find missing entries. + +## Prerequisites + +- `gh` CLI authenticated (`gh auth status`). +- Python 3.8+. + +## Subcommands + +### `issues` + +List issues in a milestone, with filtering by state, labels, and project status. + +```bash +# Closed issues in a milestone (default) +python3 tools/gh.py issues "2.16.0" + +# All issues in a milestone +python3 tools/gh.py issues "2.16.0" --state all + +# Issues with no milestone +python3 tools/gh.py issues none +python3 tools/gh.py issues none --state open + +# Filter by label (include only) +python3 tools/gh.py issues "2.16.0" --label "bug" +python3 tools/gh.py issues "2.16.0" --label "bug,regression" + +# Exclude by label +python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" + +# Show only issues NOT yet in CHANGES.md +python3 tools/gh.py issues "2.16.0" --compare CHANGES.md +``` + +**Default filters** (override with flags): +- Issues with type "Task" are excluded (`--include-tasks` to keep them). +- Issues with "Rejected" project status are excluded (`--include-rejected` to keep them). + +**Output**: JSON array to stdout; progress to stderr. + +### `prs` + +Fetch PR details by number or by milestone. + +```bash +# Fetch specific PRs +python3 tools/gh.py prs 9179 9204 9311 + +# Read PR numbers from file +python3 tools/gh.py prs --file prs.txt + +# Read PR numbers from stdin +cat prs.txt | python3 tools/gh.py prs --stdin + +# All PRs in a milestone (default: merged only) +python3 tools/gh.py prs --milestone "2.16.0" + +# All PRs in a milestone (all states) +python3 tools/gh.py prs --milestone "2.16.0" --state all +``` + +**Output**: JSON array to stdout; progress to stderr. + +## Key principles + +- All output is JSON — pipe into `jq` or other tools for further processing. +- Milestone lookup is by exact title match. +- `issues` subcommand auto-paginates (100 items per page). +- `prs` subcommand batches PR number lookups (50 per GraphQL query). diff --git a/.serena/memories/tools/nrepl-eval.md b/.serena/memories/tools/nrepl-eval.md new file mode 100644 index 0000000000..f5ec3ab994 --- /dev/null +++ b/.serena/memories/tools/nrepl-eval.md @@ -0,0 +1,126 @@ +# nREPL Eval + +Evaluate Clojure (or ClojureScript) code via a running nREPL server using +`tools/nrepl-eval.mjs` — a standalone CLI application. + +Session state (defs, in-ns, etc.) persists across invocations via a stored +session ID, so you can build up state incrementally. + +## Usage + +```bash +node tools/nrepl-eval.mjs [options] [] +# or +./tools/nrepl-eval.mjs [options] [] +``` + +## Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--backend` | Connect to backend nREPL (port 6064) | — | +| `--frontend` | Connect to frontend nREPL (port 3447) | — | +| `-p, --port PORT` | nREPL server port | `6064` | +| `-H, --host HOST` | nREPL server host | `127.0.0.1` | +| `-t, --timeout MS` | Timeout in milliseconds | `120000` | +| `--reset-session` | Discard stored session and start fresh | — | +| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — | +| `-h, --help` | Show help message | — | + +- `--backend` and `--frontend` are mutually exclusive. +- Explicit `--port` is overridden when `--backend`/`--frontend` is used. + +## When to Use + +1. **Evaluate Clojure code** during development — test functions, inspect + state, or run experiments against a running Clojure process. +2. **Verify that edited files compile** — require namespaces with `:reload` + to pick up changes. +3. **Inspect the last exception** after a failed evaluation — use `-e` to + print the error stored in `*e`. + +## Workflow + +### Session management + +Sessions are persisted to `/tmp/penpot-nrepl-session--`. State +carries across calls automatically: + +```bash +./tools/nrepl-eval.mjs '(def x 42)' +./tools/nrepl-eval.mjs 'x' +# => 42 +``` + +Reset the session to start fresh: + +```bash +./tools/nrepl-eval.mjs --reset-session '(def x 0)' +``` + +### Evaluate code + +**Single expression (inline) — uses default port 6064:** +```bash +./tools/nrepl-eval.mjs '(+ 1 2 3)' +``` + +**Backend nREPL (explicit):** +```bash +./tools/nrepl-eval.mjs --backend '(+ 1 2 3)' +``` + +**Frontend nREPL:** +```bash +./tools/nrepl-eval.mjs --frontend '(js/alert "hi")' +``` + +**Multiple expressions via heredoc (recommended — avoids escaping issues):** +```bash +./tools/nrepl-eval.mjs <<'EOF' +(def x 10) +(+ x 20) +EOF +``` + +**Override with a different port:** +```bash +./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)' +``` + +### Inspect last exception + +After code throws an error, retrieve the full exception details: + +```bash +./tools/nrepl-eval.mjs -e +``` + +## Common Patterns + +**Require a namespace with reload:** +```bash +./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)" +``` + +**Test a function:** +```bash +./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)" +``` + +**Long-running operation with custom timeout:** +```bash +./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)" +``` + +## Key Principles + +- **Default port is 6064** — just pass code directly, no `-p` needed when + your nREPL server is on 6064. Use `--backend` (6064) or `--frontend` (3447) + as quick aliases. Use `-p ` for any other port. +- **Always use `:reload`** when requiring namespaces to pick up file changes. +- **Session is reused** across invocations — defs, in-ns, and var bindings + persist. Use `--reset-session` to clear. +- **Do not start any server** — the tool connects to an existing nREPL + server, it is not the agent's responsibility to start the nREPL server + (assume the server is already running on the specified port). diff --git a/.serena/memories/tools/taiga.md b/.serena/memories/tools/taiga.md new file mode 100644 index 0000000000..b4e623e3e1 --- /dev/null +++ b/.serena/memories/tools/taiga.md @@ -0,0 +1,44 @@ +# Taiga API client + +`tools/taiga.py` fetches public issues, user stories, and tasks from the +Penpot Taiga project (id 345963) without authentication. + +## When to use + +- Fetching details of a Taiga issue, user story, or task by URL or ref number. +- Inspecting status, assignee, tags, description, and other metadata. +- Piping structured JSON into other scripts (with `--json`). + +## How to use + +```bash +# Fetch by full Taiga URL +python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714 + +# Fetch by type and ref number +python3 tools/taiga.py issue 13714 +python3 tools/taiga.py us 14128 +python3 tools/taiga.py task 13648 + +# Output raw JSON instead of formatted summary +python3 tools/taiga.py --json issue 13714 +python3 tools/taiga.py --json https://tree.taiga.io/project/penpot/us/14128 +``` + +## Supported types + +| Type | Description | +|------|-------------| +| `issue` | Bug reports, feature requests | +| `us` | User stories | +| `task` | Implementation tasks | + +## Output + +Default output is a formatted summary with title, status, assignee, author, +tags, URL, and description. Use `--json` for the raw API response. + +## Prerequisites + +- Python 3.8+ (standard library only, no dependencies). +- Network access to `api.taiga.io`. diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md index 9f3831b165..9aff0b50c1 100644 --- a/.serena/memories/workflow/creating-issues.md +++ b/.serena/memories/workflow/creating-issues.md @@ -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/` | 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 diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 4c5e75c212..c0f763f9e3 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -2,6 +2,20 @@ PR only on explicit request. Branch: issue/feature-specific; fallback `/` (`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: @@ -24,7 +38,7 @@ Include concise sections covering: PR descriptions follow this structure: ```markdown -**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative. +**Note:** This PR was created with AI assistance. ## What @@ -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' + +PR_BODY + +TARGET=$(tools/detect-target-branch) + +gh pr create \ + --repo penpot/penpot \ + --base "$TARGET" \ + --head \ + --title "" \ + --project "Main" \ + --body-file /tmp/pr-body.md + +rm -f /tmp/pr-body.md +``` diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index c4e432b225..f10b0089a0 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -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. diff --git a/backend/src/app/util/shell.clj b/backend/src/app/util/shell.clj index 0d7349d632..61dd08e682 100644 --- a/backend/src/app/util/shell.clj +++ b/backend/src/app/util/shell.clj @@ -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 [] diff --git a/backend/test/backend_tests/shell_test.clj b/backend/test/backend_tests/shell_test.clj index c77c585568..c9d1932c44 100644 --- a/backend/test/backend_tests/shell_test.clj +++ b/backend/test/backend_tests/shell_test.clj @@ -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} diff --git a/common/src/app/common/types/modifiers.cljc b/common/src/app/common/types/modifiers.cljc index dbc0b52296..34a81365f1 100644 --- a/common/src/app/common/types/modifiers.cljc +++ b/common/src/app/common/types/modifiers.cljc @@ -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)) diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index 60e736914b..2b1188682f 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -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) diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc index 2cfb196fa8..405da89935 100644 --- a/common/test/common_tests/types/modifiers_test.cljc +++ b/common/test/common_tests/types/modifiers_test.cljc @@ -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))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index f56d61a539..714270cb62 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -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 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 1b854a48cb..1fd9a7b382 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -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)"; \ diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 94193a977c..59b326c76d 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -197,10 +197,10 @@ services: - penpot environment: - << : [*penpot-secret-key] + << : [*penpot-secret-key, *penpot-public-uri] # Don't touch it; this uses an internal docker network to # communicate with the frontend. - PENPOT_PUBLIC_URI: http://penpot-frontend:8080 + PENPOT_INTERNAL_URI: http://penpot-frontend:8080 ## Valkey (or previously Redis) is used for the websockets notifications. PENPOT_REDIS_URI: redis://penpot-valkey/0 diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index eec62f63e4..49d5a23e8d 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -651,6 +651,21 @@ PENPOT_EXPORTER_URI: http://your-penpot-exporter:6061 These variables are used for generate correct nginx.conf file on container startup. +### Exporter + +The exporter uses this variable: + +```bash +# Exporter +PENPOT_INTERNAL_URI: http://penpot-frontend:8080 +``` + +- `PENPOT_INTERNAL_URI`: The URI used by the exporter's headless browser to + communicate with the frontend (internal Docker network). Defaults to + `PENPOT_PUBLIC_URI` if not set. The default value + `http://penpot-frontend:8080` used in the docker-compose is a good default and + it is recommended to keep it unchanged. + ## Other flags There are other flags that are useful for a more customized Penpot experience. This section has the list of the flags meant diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index ff32391f82..8010032680 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -22,6 +22,7 @@ (def ^:private defaults {:public-uri "http://localhost:3449" + :internal-uri nil :tenant "default" :host "localhost" :http-server-port 6061 @@ -33,6 +34,7 @@ [:map {:title "config"} [:secret-key :string] [:public-uri {:optional true} ::sm/uri] + [:internal-uri {:optional true} ::sm/uri] [:exporter-shared-key {:optional true} :string] [:host {:optional true} :string] [:tenant {:optional true} :string] @@ -100,6 +102,12 @@ ([key default] (c/get config key default))) +(defn get-internal-uri + "Returns internal-uri if set, otherwise falls back to public-uri." + [] + (or (c/get config :internal-uri) + (c/get config :public-uri))) + (def management-key (let [key (or (c/get config :exporter-shared-key) (let [secret-key (c/get config :secret-key) diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index 91d4ee702e..5ce68ebf98 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -21,6 +21,7 @@ [& _] (l/info :msg "initializing" :public-uri (str (cf/get :public-uri)) + :internal-uri (str (cf/get-internal-uri)) :version (:full cf/version)) (p/do! (bwr/init) diff --git a/exporter/src/app/handlers/resources.cljs b/exporter/src/app/handlers/resources.cljs index 9b2f37d6cc..4c4bb7225c 100644 --- a/exporter/src/app/handlers/resources.cljs +++ b/exporter/src/app/handlers/resources.cljs @@ -79,7 +79,7 @@ :method "POST" :body fdata :dispatcher agent} - uri (-> (cf/get :public-uri) + uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "api/management/methods/upload-tempfile") (str))] diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index 3579a4839e..c2720eb025 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -62,7 +62,7 @@ :skip-children skip-children :wasm (when is-wasm "true") :scale scale} - uri (-> (cf/get :public-uri) + uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "render.html") (assoc :query (u/map->query-string params)))] diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs index b953730be4..ba4118c1e8 100644 --- a/exporter/src/app/renderer/pdf.cljs +++ b/exporter/src/app/renderer/pdf.cljs @@ -77,7 +77,7 @@ (on-object (assoc object :path path)) (p/recur (rest objects))))))] - (let [base-uri (-> (cf/get :public-uri) + (let [base-uri (-> (cf/get-internal-uri) (u/ensure-path-slash))] (bw/exec! (prepare-options base-uri) (partial render base-uri))))) diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index 7a47e46fe0..c9fee2f764 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -108,6 +108,18 @@ {:width width :height height})) +(defn- replace-internal-uris + "Replaces internal-uri references with public-uri in SVG output. + This ensures that font URLs and other resource references in the + exported SVG use the public-facing URI accessible to end users." + [svg-content] + (let [internal-uri (str (cf/get-internal-uri)) + public-uri (str (cf/get :public-uri))] + (if (and (not= internal-uri public-uri) + (str/includes? svg-content internal-uri)) + (str/replace svg-content internal-uri public-uri) + svg-content))) + (defn render [{:keys [page-id file-id share-id objects token scale type]} on-object] (letfn [(convert-to-ppm [pngpath] @@ -320,7 +332,9 @@ result (if (contains? cf/flags :exporter-svgo) (svgo/optimize result svgo/defaultOptions) - result)] + result) + + result (replace-internal-uris result)] ;; (println "------- ORIGIN:") ;; (cljs.pprint/pprint (xml->clj xmldata)) @@ -349,7 +363,7 @@ :render-embed true :object-id (mapv :id objects) :route "objects"} - uri (-> (cf/get :public-uri) + uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "render.html") (assoc :query (u/map->query-string params)))] diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index 8ad22449d7..ec153b2eac 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -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": { diff --git a/frontend/packages/draft-js/pnpm-lock.yaml b/frontend/packages/draft-js/pnpm-lock.yaml deleted file mode 100644 index 6efab568fd..0000000000 --- a/frontend/packages/draft-js/pnpm-lock.yaml +++ /dev/null @@ -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 diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c11f5736a6..e7ee6eda1f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 968cc8194e..fc9911feca 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -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.zoom :as dwz] [app.main.repo :as rp] [app.main.router :as rt] @@ -71,7 +70,7 @@ (rx/take-until stopper-s)))))) -(defn- handle-interrupt +(defn handle-interrupt [] (ptk/reify ::handle-interrupt ptk/WatchEvent @@ -85,8 +84,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 diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 96a1605704..b5696414c8 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -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))] diff --git a/frontend/src/app/main/ui/components/select.scss b/frontend/src/app/main/ui/components/select.scss index 6baab75ac1..438e0873fa 100644 --- a/frontend/src/app/main/ui/components/select.scss +++ b/frontend/src/app/main/ui/components/select.scss @@ -150,7 +150,7 @@ .separator { margin: 0; block-size: $sz-12; - border-block-start: $b-1 solid var(--color-background-primary); + border-block-start: $b-1 solid var(--color-background-quaternary); } &[data-direction="up"] { diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index a22b82f46a..ae6f49f688 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -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) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 536c087b36..2851124260 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index b39d62c1cb..8e21f18232 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -171,12 +171,20 @@ on-caps-start-change (mf/use-fn (mf/deps index on-stroke-cap-start-change) - #(on-stroke-cap-start-change index (keyword %))) + (fn [cap] + (let [cap (if (= cap "none") + nil + (keyword cap))] + (on-stroke-cap-start-change index cap)))) on-caps-end-change (mf/use-fn (mf/deps index on-stroke-cap-end-change) - #(on-stroke-cap-end-change index (keyword %))) + (fn [cap] + (let [cap (if (= cap "none") + nil + (keyword cap))] + (on-stroke-cap-end-change index cap)))) on-detach-token-color (mf/use-fn @@ -191,16 +199,16 @@ (on-detach-token token #{:stroke-width}))) stroke-caps-options - [{:value nil :label (tr "workspace.options.stroke-cap.none")} - :separator - {:value :line-arrow :label (tr "workspace.options.stroke-cap.line-arrow-short") :icon :stroke-arrow} - {:value :triangle-arrow :label (tr "workspace.options.stroke-cap.triangle-arrow-short") :icon :stroke-triangle} - {:value :square-marker :label (tr "workspace.options.stroke-cap.square-marker-short") :icon :stroke-rectangle} - {:value :circle-marker :label (tr "workspace.options.stroke-cap.circle-marker-short") :icon :stroke-circle} - {:value :diamond-marker :label (tr "workspace.options.stroke-cap.diamond-marker-short") :icon :stroke-diamond} - :separator - {:value :round :label (tr "workspace.options.stroke-cap.round") :icon :stroke-rounded} - {:value :square :label (tr "workspace.options.stroke-cap.square") :icon :stroke-squared}] + [{:id "none" :value "none" :label (tr "workspace.options.stroke-cap.none")} + {:label "" :type :separator :id "separator"} + {:id "line-arrow" :value :line-arrow :label (tr "workspace.options.stroke-cap.line-arrow-short") :icon i/stroke-arrow} + {:id "triangle-arrow" :value :triangle-arrow :label (tr "workspace.options.stroke-cap.triangle-arrow-short") :icon i/stroke-triangle} + {:id "square-marker" :value :square-marker :label (tr "workspace.options.stroke-cap.square-marker-short") :icon i/stroke-rectangle} + {:id "circle-marker" :value :circle-marker :label (tr "workspace.options.stroke-cap.circle-marker-short") :icon i/stroke-circle} + {:id "diamond-marker" :value :diamond-marker :label (tr "workspace.options.stroke-cap.diamond-marker-short") :icon i/stroke-diamond} + {:label "" :type :separator :id "separator"} + {:id "round" :value :round :label (tr "workspace.options.stroke-cap.round") :icon i/stroke-rounded} + {:id "square" :value :square :label (tr "workspace.options.stroke-cap.square") :icon i/stroke-squared}] on-cap-switch (mf/use-fn @@ -340,16 +348,18 @@ ;; Stroke Caps (when show-caps [:div {:class (stl/css :stroke-caps-options)} - [:& select {:default-value (:stroke-cap-start stroke) - :options stroke-caps-options - :disabled hidden? - :on-change on-caps-start-change}] + [:> select* {:default-selected (or (d/name (:stroke-cap-start stroke)) "none") + :options stroke-caps-options + :data-testid "stroke.cap-start" + :disabled hidden? + :on-change on-caps-start-change}] [:> icon-button* {:variant "secondary" :aria-label (tr "labels.switch") :disabled hidden? :on-click on-cap-switch :icon i/switch}] - [:& select {:default-value (:stroke-cap-end stroke) - :options stroke-caps-options - :disabled hidden? - :on-change on-caps-end-change}]])])) + [:> select* {:default-selected (or (d/name (:stroke-cap-end stroke)) "none") + :options stroke-caps-options + :data-testid "stroke.cap-end" + :disabled hidden? + :on-change on-caps-end-change}]])])) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index b97343ec20..9fb646f205 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -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) diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index 76bc45a832..e668bc8756 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -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 diff --git a/frontend/test/frontend_tests/data/workspace_comments_test.cljs b/frontend/test/frontend_tests/data/workspace_comments_test.cljs new file mode 100644 index 0000000000..8e653875c1 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_comments_test.cljs @@ -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))))))) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 15db703ef8..ee438672c5 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -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)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 0e92dd804a..f21b124277 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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] @@ -85,6 +86,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 diff --git a/package.json b/package.json index 77800eaca6..1454dbd2bc 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 00095c38b5..80dee3f1c9 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -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) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts index 5f16958282..7d3a8ccf0a 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts @@ -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); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts index 254717e63e..5564b2e3f1 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/plugin-data.test.ts @@ -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); diff --git a/tools/detect-target-branch b/tools/detect-target-branch new file mode 100755 index 0000000000..5d3d584552 --- /dev/null +++ b/tools/detect-target-branch @@ -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 diff --git a/tools/nrepl-eval.mjs b/tools/nrepl-eval.mjs index 45da2445de..8a10905305 100755 --- a/tools/nrepl-eval.mjs +++ b/tools/nrepl-eval.mjs @@ -128,18 +128,24 @@ function formatEvalMessages(messages) { function parseArgs(argv) { const args = { - port: 6064, + port: null, host: "127.0.0.1", timeout: DEFAULT_TIMEOUT, help: false, resetSession: false, lastError: false, + backend: false, + frontend: false, code: null, }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "-p" || a === "--port") { + if (a === "--backend") { + args.backend = true; + } else if (a === "--frontend") { + args.frontend = true; + } else if (a === "-p" || a === "--port") { const val = argv[++i]; if (val === undefined) { console.error("Error: --port requires a value."); process.exit(1); } args.port = parseInt(val, 10); @@ -166,6 +172,19 @@ function parseArgs(argv) { } } + if (args.backend && args.frontend) { + console.error("Error: --backend and --frontend are mutually exclusive."); + process.exit(1); + } + + if (args.backend) { + args.port = 6064; + } else if (args.frontend) { + args.port = 3447; + } else if (args.port === null) { + args.port = 6064; + } + return args; } @@ -177,7 +196,9 @@ Evaluate Clojure code via a running nREPL server. Session state (defs, in-ns) persists across invocations via a stored session ID. Options: - -p, --port PORT nREPL port (default: 6064) + --backend Connect to backend nREPL on port 6064 (default) + --frontend Connect to frontend nREPL on port 3447 + -p, --port PORT nREPL port (default: 6064; overridden by --backend/--frontend) -H, --host HOST nREPL host (default: 127.0.0.1) -t, --timeout MILLISECONDS Timeout in milliseconds (default: 120000) --reset-session Discard stored session and start fresh @@ -187,6 +208,8 @@ Options: Examples: ${bin} '(def x 42)' ${bin} 'x' + ${bin} --backend '(+ 1 2 3)' + ${bin} --frontend '(js/alert "hi")' ${bin} --reset-session '(def x 0)' ${bin} --last-error ${bin} <<'EOF'