mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
✨ Add project and file discovery to penpotMgmt
Discovery: - Add listProjects and project-scoped listFiles with content:read checks. - Return metadata from existing RPCs in dashboard and workspace contexts. - Reject invalid IDs and preserve backend access errors. Documentation: - Keep usage in API type comments and include regenerated MCP API docs. - Remove README implementation details and consolidate the changelog. - Add usage information to the MCP server's initial instructions Validation: - Pass 8 frontend tests, 81 runtime tests and the runtime build. - Verify both APIs through live MCP calls in dashboard and workspace. - Pass type, lint, formatting and diff checks. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain <dominik.jain@oraios-ai.de>
This commit is contained in:
parent
54cce66c13
commit
2bb6cd557e
@ -8,9 +8,11 @@
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.util.object :as obj]))
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]))
|
||||
|
||||
(defn workspace-context
|
||||
[state]
|
||||
@ -70,9 +72,51 @@
|
||||
(finish (js/Error. "File navigation was superseded"))))))
|
||||
(st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id}))))))))
|
||||
|
||||
(defn list-projects
|
||||
[options]
|
||||
(let [team-id (if-let [id (obj/get options "teamId")]
|
||||
(uuid/parse* id)
|
||||
(:current-team-id @st/state))]
|
||||
(if (nil? team-id)
|
||||
(js/Promise.reject (js/Error. "Expected a team UUID"))
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(->> (rp/cmd! :get-projects {:team-id team-id})
|
||||
(rx/map (fn [projects]
|
||||
(clj->js
|
||||
(mapv (fn [project]
|
||||
{:id (str (:id project))
|
||||
:teamId (str (:team-id project))
|
||||
:name (:name project)
|
||||
:isDefault (boolean (:is-default project))
|
||||
:fileCount (:count project)})
|
||||
(remove :deleted-at projects)))))
|
||||
(rx/subs! resolve reject)))))))
|
||||
|
||||
(defn list-files
|
||||
[options]
|
||||
(let [project-id (uuid/parse* (obj/get options "projectId"))]
|
||||
(if (nil? project-id)
|
||||
(js/Promise.reject (js/Error. "Expected a project UUID"))
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(->> (rp/cmd! :get-project-files {:project-id project-id})
|
||||
(rx/map (fn [files]
|
||||
(clj->js
|
||||
(mapv (fn [file]
|
||||
{:id (str (:id file))
|
||||
:teamId (str (:team-id file))
|
||||
:projectId (str (:project-id file))
|
||||
:name (:name file)
|
||||
:modifiedAt (.toISOString ^js (:modified-at file))})
|
||||
files))))
|
||||
(rx/subs! resolve reject)))))))
|
||||
|
||||
(defn create-context
|
||||
[]
|
||||
(obj/reify {:name "PenpotManagementContext"}
|
||||
:workspace
|
||||
{:get (fn [] (clj->js (workspace-context @st/state)))}
|
||||
:openFile open-file))
|
||||
:openFile open-file
|
||||
:listProjects list-projects
|
||||
:listFiles list-files))
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
(ns frontend-tests.plugins.management-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.events :as events]
|
||||
[app.plugins.management :as management]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.async :as a]
|
||||
[frontend-tests.helpers.mock :as mock]))
|
||||
@ -81,3 +83,72 @@
|
||||
(let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)})
|
||||
(.then (fn [] nil) (fn [error] (.-message error))))]
|
||||
(t/is (= "Expected a file UUID and a team UUID" (await result)))))
|
||||
|
||||
(t/deftest ^:async list-projects-returns-active-metadata
|
||||
(let [project-id (uuid/next)
|
||||
other-team-id (uuid/next)]
|
||||
(doseq [[state options expected-team-id]
|
||||
[[dashboard nil team-id]
|
||||
[ready #js {:teamId (str other-team-id)} other-team-id]]]
|
||||
(let [state* (atom state)]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state*
|
||||
rp/cmd! (mock/stub
|
||||
(fn [command params]
|
||||
(t/is (= [:get-projects {:team-id expected-team-id}]
|
||||
[command params]))
|
||||
(rx/of [{:id project-id :team-id expected-team-id
|
||||
:name "Drafts" :is-default true :count 2 :total-count 3}
|
||||
{:id (uuid/next) :deleted-at (js/Date.)}])))}
|
||||
(let [projects (await (management/list-projects options))]
|
||||
(t/is (= [{"id" (str project-id) "teamId" (str expected-team-id)
|
||||
"name" "Drafts" "isDefault" true "fileCount" 2}]
|
||||
(js->clj projects)))
|
||||
(t/is (= state @state*)))))))))
|
||||
|
||||
(t/deftest ^:async list-files-returns-metadata-without-opening-a-file
|
||||
(let [project-id (uuid/next)]
|
||||
(doseq [state [dashboard ready]]
|
||||
(let [state* (atom state)]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state*
|
||||
rp/cmd! (mock/stub
|
||||
(fn [command params]
|
||||
(t/is (= [:get-project-files {:project-id project-id}]
|
||||
[command params]))
|
||||
(rx/of [{:id file-id :team-id team-id :project-id project-id
|
||||
:name "Design" :modified-at (js/Date. "2026-01-02T03:04:05Z")
|
||||
:revn 10}])))}
|
||||
(let [files (await (management/list-files #js {:projectId (str project-id)}))]
|
||||
(t/is (= [{"id" (str file-id) "teamId" (str team-id)
|
||||
"projectId" (str project-id) "name" "Design"
|
||||
"modifiedAt" "2026-01-02T03:04:05.000Z"}]
|
||||
(js->clj files)))
|
||||
(t/is (= state @state*)))))))))
|
||||
|
||||
(t/deftest ^:async listing-rejects-invalid-identifiers-without-querying
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state (atom {})
|
||||
rp/cmd! (mock/stub (fn [_ _] (t/is false "Invalid IDs must not query the backend")))}
|
||||
(doseq [[result message]
|
||||
[[(management/list-projects nil) "Expected a team UUID"]
|
||||
[(management/list-projects #js {:teamId "invalid"}) "Expected a team UUID"]
|
||||
[(management/list-files nil) "Expected a project UUID"]
|
||||
[(management/list-files #js {:projectId "invalid"}) "Expected a project UUID"]]]
|
||||
(t/is (= message (await (.then result (fn [] nil) #(.-message %)))))))))
|
||||
|
||||
(t/deftest ^:async listing-preserves-empty-results-and-backend-errors
|
||||
(doseq [list-items [(fn [] (management/list-projects #js {:teamId (str team-id)}))
|
||||
(fn [] (management/list-files #js {:projectId (str (uuid/next))}))]]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{rp/cmd! (mock/stub (fn [_ _] (rx/of [])))}
|
||||
(t/is (= [] (js->clj (await (list-items)))))))
|
||||
(let [error (js/Error. "Access denied")]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))}
|
||||
(t/is (identical? error (await (.then (list-items) (fn [] nil) identity)))))))))
|
||||
|
||||
@ -33,16 +33,6 @@ This repository thus contains not only the MCP server implementation itself
|
||||
but also the supporting Penpot MCP Plugin
|
||||
(see section [Repository Structure](#repository-structure) below).
|
||||
|
||||
## Global plugin lifecycle
|
||||
|
||||
- The MCP plugin stays loaded across dashboard and workspace navigation, including when the integration runs with its UI hidden.
|
||||
- Enabling the integration loads the plugin; connecting remains an explicit action in the current tab. Logout closes it.
|
||||
- The MCP session ID is a short, 10-character Base32 hash of `penpot.currentUser.sessionId` alone. File metadata changes without replacing the WebSocket connection or execution `storage`.
|
||||
- `penpotMgmt.workspace` reports `none`, `loading` or `ready`, with file and team metadata. Subscribe through `penpotMgmt.on("workspacechange", callback)`.
|
||||
- `await penpotMgmt.openFile(fileId, {teamId})` opens a file in the same tab and waits for its page to be ready. The team defaults to the current team; failed navigation rejects the promise.
|
||||
- The `penpot` facade stays live. File operations require a ready workspace; reacquire file, page and shape references after navigation.
|
||||
- Global plugins declare `"scope": "global"` in their manifest. Management metadata and navigation currently require `content:read` permission.
|
||||
|
||||
## Demonstration
|
||||
|
||||
[](https://v32155.1blu.de/penpot/PenpotFest2025.mp4)
|
||||
|
||||
@ -92,7 +92,7 @@ Penpot:
|
||||
|
||||
Hierarchy
|
||||
|
||||
* Omit<Context, "addListener" | "removeListener">
|
||||
* Omit<Context, "addListener" | "removeListener" | "management">
|
||||
+ Penpot
|
||||
members:
|
||||
Properties:
|
||||
@ -5001,6 +5001,250 @@ CommonLayout:
|
||||
|
||||
The `remove` method removes the layout.
|
||||
|
||||
Returns void
|
||||
WorkspaceContext:
|
||||
overview: |-
|
||||
Interface WorkspaceContext
|
||||
==========================
|
||||
|
||||
Current workspace of this browser tab.
|
||||
|
||||
```
|
||||
interface WorkspaceContext {
|
||||
status: "none" | "loading" | "ready";
|
||||
fileId: string | null;
|
||||
fileName: string | null;
|
||||
teamId: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
Referenced by: EventsMap, PenpotMgmt
|
||||
members:
|
||||
Properties:
|
||||
status: |-
|
||||
```
|
||||
readonly status: "none" | "loading" | "ready"
|
||||
```
|
||||
fileId: |-
|
||||
```
|
||||
readonly fileId: string | null
|
||||
```
|
||||
fileName: |-
|
||||
```
|
||||
readonly fileName: string | null
|
||||
```
|
||||
teamId: |-
|
||||
```
|
||||
readonly teamId: string | null
|
||||
```
|
||||
ProjectSummary:
|
||||
overview: |-
|
||||
Interface ProjectSummary
|
||||
========================
|
||||
|
||||
Metadata snapshot of an accessible, non-deleted project.
|
||||
|
||||
```
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
teamId: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
fileCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
Referenced by: PenpotMgmt
|
||||
members:
|
||||
Properties:
|
||||
id: |-
|
||||
```
|
||||
readonly id: string
|
||||
```
|
||||
teamId: |-
|
||||
```
|
||||
readonly teamId: string
|
||||
```
|
||||
name: |-
|
||||
```
|
||||
readonly name: string
|
||||
```
|
||||
isDefault: |-
|
||||
```
|
||||
readonly isDefault: boolean
|
||||
```
|
||||
|
||||
Whether this is the team's Drafts project.
|
||||
fileCount: |-
|
||||
```
|
||||
readonly fileCount: number
|
||||
```
|
||||
FileSummary:
|
||||
overview: |-
|
||||
Interface FileSummary
|
||||
=====================
|
||||
|
||||
Metadata snapshot of an accessible, non-deleted file.
|
||||
|
||||
```
|
||||
interface FileSummary {
|
||||
id: string;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
modifiedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Referenced by: PenpotMgmt
|
||||
members:
|
||||
Properties:
|
||||
id: |-
|
||||
```
|
||||
readonly id: string
|
||||
```
|
||||
teamId: |-
|
||||
```
|
||||
readonly teamId: string
|
||||
```
|
||||
projectId: |-
|
||||
```
|
||||
readonly projectId: string
|
||||
```
|
||||
name: |-
|
||||
```
|
||||
readonly name: string
|
||||
```
|
||||
modifiedAt: |-
|
||||
```
|
||||
readonly modifiedAt: string
|
||||
```
|
||||
|
||||
Last modification time as an ISO 8601 string in UTC.
|
||||
PenpotMgmt:
|
||||
overview: |-
|
||||
Interface PenpotMgmt
|
||||
====================
|
||||
|
||||
Application management API exposed as `penpotMgmt` to global plugins.
|
||||
|
||||
* Requires `content:read` permission.
|
||||
* Available in the dashboard and file workspaces.
|
||||
* Use this API to discover and open files; file operations on `penpot` require a ready workspace.
|
||||
* Reacquire file, page and shape objects after changing files.
|
||||
|
||||
```
|
||||
interface PenpotMgmt {
|
||||
workspace: WorkspaceContext;
|
||||
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>;
|
||||
listFiles(options: { projectId: string }): Promise<FileSummary[]>;
|
||||
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
|
||||
on(
|
||||
type: "workspacechange",
|
||||
callback: (workspace: WorkspaceContext) => void,
|
||||
): symbol;
|
||||
off(listenerId: symbol): void;
|
||||
}
|
||||
```
|
||||
|
||||
Referenced by: Context
|
||||
members:
|
||||
Properties:
|
||||
workspace: |-
|
||||
```
|
||||
readonly workspace: WorkspaceContext
|
||||
```
|
||||
|
||||
Current workspace availability and metadata; usable for file operations when status is `ready`.
|
||||
Methods:
|
||||
listProjects: |-
|
||||
```
|
||||
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>
|
||||
```
|
||||
|
||||
Lists accessible, non-deleted projects as metadata snapshots.
|
||||
|
||||
* `teamId` defaults to the current team.
|
||||
* Queries the backend without opening a file or changing navigation.
|
||||
* Rejects invalid identifiers and backend access errors.
|
||||
|
||||
Parameters
|
||||
|
||||
* options: { teamId?: string }
|
||||
|
||||
Returns Promise<ProjectSummary[]>
|
||||
|
||||
Example
|
||||
```
|
||||
const projects = await penpotMgmt.listProjects();
|
||||
```
|
||||
listFiles: |-
|
||||
```
|
||||
listFiles(options: { projectId: string }): Promise<FileSummary[]>
|
||||
```
|
||||
|
||||
Lists accessible, non-deleted files in the specified project as metadata snapshots.
|
||||
|
||||
* Requires an explicit `projectId`.
|
||||
* Queries the backend without opening a file or changing navigation.
|
||||
* Rejects invalid identifiers and backend access errors.
|
||||
|
||||
Parameters
|
||||
|
||||
* options: { projectId: string }
|
||||
|
||||
Returns Promise<FileSummary[]>
|
||||
|
||||
Example
|
||||
```
|
||||
const files = await penpotMgmt.listFiles({ projectId: project.id });
|
||||
```
|
||||
openFile: |-
|
||||
```
|
||||
openFile(fileId: string, options?: { teamId?: string }): Promise<void>
|
||||
```
|
||||
|
||||
Opens a file in this tab and resolves once its workspace and page are ready.
|
||||
|
||||
* `teamId` defaults to the current team.
|
||||
* Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds.
|
||||
|
||||
Parameters
|
||||
|
||||
* fileId: string
|
||||
* options: { teamId?: string }
|
||||
|
||||
Returns Promise<void>
|
||||
|
||||
Example
|
||||
```
|
||||
await penpotMgmt.openFile(file.id, { teamId: file.teamId });
|
||||
```
|
||||
on: |-
|
||||
```
|
||||
on(
|
||||
type: "workspacechange",
|
||||
callback: (workspace: WorkspaceContext) => void,
|
||||
): symbol
|
||||
```
|
||||
|
||||
Subscribes to workspace availability and metadata changes.
|
||||
|
||||
Parameters
|
||||
|
||||
* type: "workspacechange"
|
||||
* callback: (workspace: WorkspaceContext) => void
|
||||
|
||||
Returns symbol
|
||||
off: |-
|
||||
```
|
||||
off(listenerId: symbol): void
|
||||
```
|
||||
|
||||
Parameters
|
||||
|
||||
* listenerId: symbol
|
||||
|
||||
Returns void
|
||||
Context:
|
||||
overview: |-
|
||||
@ -5011,6 +5255,13 @@ Context:
|
||||
|
||||
```
|
||||
interface Context {
|
||||
management?: Pick<
|
||||
PenpotMgmt,
|
||||
"workspace"
|
||||
| "openFile"
|
||||
| "listProjects"
|
||||
| "listFiles",
|
||||
>;
|
||||
version: string;
|
||||
root: Shape | null;
|
||||
currentFile: File | null;
|
||||
@ -5082,6 +5333,16 @@ Context:
|
||||
```
|
||||
members:
|
||||
Properties:
|
||||
management: |-
|
||||
```
|
||||
readonly management?: Pick<
|
||||
PenpotMgmt,
|
||||
"workspace"
|
||||
| "openFile"
|
||||
| "listProjects"
|
||||
| "listFiles",
|
||||
>
|
||||
```
|
||||
version: |-
|
||||
```
|
||||
readonly version: string
|
||||
@ -7109,6 +7370,8 @@ EventsMap:
|
||||
|
||||
```
|
||||
interface EventsMap {
|
||||
workspacechange: WorkspaceContext;
|
||||
logout: void;
|
||||
pagechange: Page;
|
||||
filechange: File;
|
||||
selectionchange: string[];
|
||||
@ -7122,6 +7385,14 @@ EventsMap:
|
||||
Referenced by: Context, Penpot
|
||||
members:
|
||||
Properties:
|
||||
workspacechange: |-
|
||||
```
|
||||
readonly workspacechange: WorkspaceContext
|
||||
```
|
||||
logout: |-
|
||||
```
|
||||
readonly logout: void
|
||||
```
|
||||
pagechange: |-
|
||||
```
|
||||
readonly pagechange: Page
|
||||
|
||||
@ -166,13 +166,15 @@ Boards can have layout systems that automatically control the positioning and sp
|
||||
|
||||
# The `penpot` and `penpotUtils` Objects, Exploring Designs
|
||||
|
||||
A key object to use in your code is the `penpot` object (which is of type `Penpot`):
|
||||
A key object for navigating the currently active Penpot file is the `penpot` object (which is of type `Penpot`):
|
||||
* `penpot.selection` provides the list of shapes the user has selected in the Penpot UI.
|
||||
If it is unclear which elements to work on, you can ask the user to select them for you.
|
||||
ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged.
|
||||
* `penpot.root` provides the root shape of the currently active page.
|
||||
* Generation of CSS content for elements via `penpot.generateStyle`
|
||||
* Generation of HTML/SVG content for elements via `penpot.generateMarkup`
|
||||
Members of `penpot` (e.g. `penpot.currentFile` and `penpot.root`) will be null no Penpot file is currently opened.
|
||||
You can use `penpotMgmt` to navigate to a file if needed (see below).
|
||||
|
||||
For example, to generate CSS for the currently selected elements, you can execute this:
|
||||
return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true });
|
||||
@ -430,5 +432,11 @@ For many tasks, it can be critical to visually inspect the design. Remember to u
|
||||
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
|
||||
Consider converting boards to flex layout when appropriate.
|
||||
|
||||
# The `penpotMgmt` Object
|
||||
|
||||
This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files.
|
||||
It allows you to discover and navigate to different design files the user has access to.
|
||||
You navigate to different design files only when your task explicitly requires it/you are instructed to do so.
|
||||
|
||||
--
|
||||
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.
|
||||
|
||||
@ -49,10 +49,7 @@ export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
|
||||
"Executes JavaScript code in the Penpot plugin context.\n" +
|
||||
"IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " +
|
||||
"which Penpot API functionality is necessary and how to use it.\n" +
|
||||
"You have access to `penpot` (the Penpot API, of type `Penpot`), `penpotMgmt`, `penpotUtils`, and `storage`.\n" +
|
||||
"`penpotMgmt.workspace.status` is none, loading or ready. Use `await penpotMgmt.openFile(fileId, {teamId})` " +
|
||||
"to open a file and wait until it is ready; teamId defaults to the current team. " +
|
||||
"File operations require a ready workspace. Storage survives navigation; reacquire file and shape objects after changing files.\n" +
|
||||
"You have access to `penpot` (of type `Penpot`), `penpotMgmt` (of type `PenpotMgmt`), `penpotUtils`, and `storage`.\n" +
|
||||
"`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " +
|
||||
"stored attributes can be referenced in future calls to this tool, so any intermediate results that " +
|
||||
"could come in handy later should be stored in `storage` instead of just a fleeting variable; " +
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **plugins-runtime**: Added manifest `scope: "global"` for plugins that survive workspace navigation and close on logout.
|
||||
- **plugin-types**: Added `penpotMgmt` with workspace readiness, `workspacechange` events and `openFile()`, available to global plugins with `content:read` permission.
|
||||
- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until logout, with `penpotMgmt` for project and file discovery, file navigation and workspace events.
|
||||
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished
|
||||
|
||||
73
plugins/libs/plugin-types/index.d.ts
vendored
73
plugins/libs/plugin-types/index.d.ts
vendored
@ -786,10 +786,74 @@ export interface WorkspaceContext {
|
||||
readonly teamId: string | null;
|
||||
}
|
||||
|
||||
/** Application management API available to global plugins. */
|
||||
/** Metadata snapshot of an accessible, non-deleted project. */
|
||||
export interface ProjectSummary {
|
||||
readonly id: string;
|
||||
readonly teamId: string;
|
||||
readonly name: string;
|
||||
/** Whether this is the team's Drafts project. */
|
||||
readonly isDefault: boolean;
|
||||
readonly fileCount: number;
|
||||
}
|
||||
|
||||
/** Metadata snapshot of an accessible, non-deleted file. */
|
||||
export interface FileSummary {
|
||||
readonly id: string;
|
||||
readonly teamId: string;
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
/** Last modification time as an ISO 8601 string in UTC. */
|
||||
readonly modifiedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application management API exposed as `penpotMgmt` to global plugins.
|
||||
*
|
||||
* - Requires `content:read` permission.
|
||||
* - Available in the dashboard and file workspaces.
|
||||
* - Use this API to discover and open files; file operations on `penpot` require a ready workspace.
|
||||
* - Reacquire file, page and shape objects after changing files.
|
||||
*/
|
||||
export interface PenpotMgmt {
|
||||
/** Current workspace availability and metadata; usable for file operations when status is `ready`. */
|
||||
readonly workspace: WorkspaceContext;
|
||||
/** Opens a file in this tab and resolves once its workspace and page are ready. */
|
||||
/**
|
||||
* Lists accessible, non-deleted projects as metadata snapshots.
|
||||
*
|
||||
* - `teamId` defaults to the current team.
|
||||
* - Queries the backend without opening a file or changing navigation.
|
||||
* - Rejects invalid identifiers and backend access errors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const projects = await penpotMgmt.listProjects();
|
||||
* ```
|
||||
*/
|
||||
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>;
|
||||
/**
|
||||
* Lists accessible, non-deleted files in the specified project as metadata snapshots.
|
||||
*
|
||||
* - Requires an explicit `projectId`.
|
||||
* - Queries the backend without opening a file or changing navigation.
|
||||
* - Rejects invalid identifiers and backend access errors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const files = await penpotMgmt.listFiles({ projectId: project.id });
|
||||
* ```
|
||||
*/
|
||||
listFiles(options: { projectId: string }): Promise<FileSummary[]>;
|
||||
/**
|
||||
* Opens a file in this tab and resolves once its workspace and page are ready.
|
||||
*
|
||||
* - `teamId` defaults to the current team.
|
||||
* - Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await penpotMgmt.openFile(file.id, { teamId: file.teamId });
|
||||
* ```
|
||||
*/
|
||||
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
|
||||
/** Subscribes to workspace availability and metadata changes. */
|
||||
on(
|
||||
@ -803,7 +867,10 @@ export interface PenpotMgmt {
|
||||
* Represents the context of Penpot, providing access to various Penpot functionalities and data.
|
||||
*/
|
||||
export interface Context {
|
||||
readonly management?: Pick<PenpotMgmt, 'workspace' | 'openFile'>;
|
||||
readonly management?: Pick<
|
||||
PenpotMgmt,
|
||||
'workspace' | 'openFile' | 'listProjects' | 'listFiles'
|
||||
>;
|
||||
/**
|
||||
* Returns the current penpot version.
|
||||
*/
|
||||
|
||||
@ -419,6 +419,14 @@ export function createApi(
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.workspace;
|
||||
},
|
||||
listProjects(options) {
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.listProjects(options);
|
||||
},
|
||||
listFiles(options) {
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.listFiles(options);
|
||||
},
|
||||
openFile(fileId, options) {
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.openFile(fileId, options);
|
||||
|
||||
@ -54,6 +54,60 @@ describe('Plugin api', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('management listing', () => {
|
||||
it.each(['none', 'loading', 'ready'])(
|
||||
'lists metadata with workspace status %s',
|
||||
async (status) => {
|
||||
const projects = [{ id: 'project-id' }];
|
||||
const files = [{ id: 'file-id' }];
|
||||
const management = {
|
||||
workspace: { status },
|
||||
listProjects: vi.fn().mockResolvedValue(projects),
|
||||
listFiles: vi.fn().mockResolvedValue(files),
|
||||
};
|
||||
const { penpotMgmt } = createApi({
|
||||
...pluginManager,
|
||||
manifest: { ...pluginManager.manifest, scope: 'global' },
|
||||
context: { ...pluginManager.context, management },
|
||||
} as any);
|
||||
|
||||
await expect(penpotMgmt.listProjects()).resolves.toEqual(projects);
|
||||
expect(management.listProjects).toHaveBeenLastCalledWith(undefined);
|
||||
await penpotMgmt.listProjects({ teamId: 'team-id' });
|
||||
expect(management.listProjects).toHaveBeenLastCalledWith({
|
||||
teamId: 'team-id',
|
||||
});
|
||||
await expect(
|
||||
penpotMgmt.listFiles({ projectId: 'project-id' }),
|
||||
).resolves.toEqual(files);
|
||||
expect(management.listFiles).toHaveBeenCalledWith({
|
||||
projectId: 'project-id',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('requires content:read before querying metadata', () => {
|
||||
const management = {
|
||||
listProjects: vi.fn(),
|
||||
listFiles: vi.fn(),
|
||||
};
|
||||
const { penpotMgmt } = createApi({
|
||||
...pluginManager,
|
||||
manifest: {
|
||||
...pluginManager.manifest,
|
||||
permissions: [],
|
||||
scope: 'global',
|
||||
},
|
||||
context: { ...pluginManager.context, management },
|
||||
} as any);
|
||||
|
||||
expect(() => penpotMgmt.listProjects()).toThrow();
|
||||
expect(() => penpotMgmt.listFiles({ projectId: 'project-id' })).toThrow();
|
||||
expect(management.listProjects).not.toHaveBeenCalled();
|
||||
expect(management.listFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui', () => {
|
||||
describe.concurrent('permissions', () => {
|
||||
const api = createApi({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user