deck-plugin-sdk 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,6 +43,8 @@ export interface LayoutTab {
43
43
  * tab that has a cell to itself, which is every tab written before zones held several.
44
44
  */
45
45
  hidden?: boolean;
46
+ /** The tab group it is in (LayoutWorkspace.groups); a group's tabs sit together in one zone. */
47
+ group?: string;
46
48
  }
47
49
  export interface LayoutWorkspace {
48
50
  id: string;
@@ -50,6 +52,8 @@ export interface LayoutWorkspace {
50
52
  cwd: string;
51
53
  tabs: LayoutTab[];
52
54
  activeTab: string;
55
+ /** Tab groups: a name and colour over tabs that sit together in a zone's row, folded or not. */
56
+ groups?: TabGroup[];
53
57
  /** Its colour on the sidebar's collapsed rail, by index (Terminals' TONES); none picks one from the name. */
54
58
  color?: number;
55
59
  }
@@ -121,7 +125,53 @@ export declare function moveZone(tabs: readonly LayoutTab[], id: string, to: {
121
125
  * Every tab's cell is pinned first (placeAll): a tab with no stored position takes its cell from
122
126
  * its index, so reordering the list could otherwise move a zone you never touched.
123
127
  */
124
- export declare function reorderTab(tabs: readonly LayoutTab[], srcId: string, overId: string, after: boolean): LayoutTab[];
128
+ export declare function reorderTab(tabs: readonly LayoutTab[], srcId: string, overId: string, after: boolean,
129
+ /** A drag in the strip says which group the tab ends up in: the one of the tab it is held over
130
+ * (null: none, held over a tab outside any group or over a group's chip). Left out, the tabs
131
+ * either side decide (settleGroup). */
132
+ group?: string | null): LayoutTab[];
133
+ /** A tab group: its tabs carry its id. Its colour is an index into GROUP_COLORS. */
134
+ export interface TabGroup {
135
+ id: string;
136
+ name: string;
137
+ color: number;
138
+ folded?: boolean;
139
+ }
140
+ /** Chrome's group colours, tuned for Deck's dark surfaces. */
141
+ export declare const GROUP_COLORS: string[];
142
+ /**
143
+ * Put a tab in a group: `groupId`, or a new one in the next colour not yet used (its id is
144
+ * returned, so its editor can open). The tab moves next to the group's other tabs.
145
+ */
146
+ export declare function addToGroup(w: LayoutWorkspace, tabId: string, groupId?: string): {
147
+ w: LayoutWorkspace;
148
+ groupId: string;
149
+ };
150
+ /** Take a tab out of its group, to just after the group's tabs. */
151
+ export declare function leaveGroup(w: LayoutWorkspace, tabId: string): LayoutWorkspace;
152
+ /**
153
+ * Move a whole group, its tabs in their order, to just before or after `overId`. Over another
154
+ * group's tab it goes before or after that group as a whole, never into the middle of it.
155
+ */
156
+ export declare function moveGroup(tabs: readonly LayoutTab[], groupId: string, overId: string, after: boolean): LayoutTab[];
157
+ /**
158
+ * Fold a group, or open it. A group holding the tab its zone shows is not folded over it: the
159
+ * zone moves to the nearest tab outside the group that is not itself folded away (after the group
160
+ * first, then before it). With none, `makeTab` gives a new tab for the zone to open on; without
161
+ * it, the group stays open.
162
+ */
163
+ export declare function toggleGroupFold(w: LayoutWorkspace, groupId: string, makeTab?: () => LayoutTab): LayoutWorkspace;
164
+ /** The group goes; its tabs stay where they are. */
165
+ export declare function ungroup(w: LayoutWorkspace, groupId: string): LayoutWorkspace;
166
+ /** Change a group's name, colour or fold. */
167
+ export declare function setGroup(w: LayoutWorkspace, groupId: string, patch: Partial<Omit<TabGroup, "id">>): LayoutWorkspace;
168
+ /**
169
+ * The open tab is never folded away: however it came to be open (the tab beside a closed one, the
170
+ * switcher, a shortcut, an agent's notification), a folded group holding it opens.
171
+ */
172
+ export declare function openActiveGroup(w: LayoutWorkspace): LayoutWorkspace;
173
+ /** A group with no tabs left goes (its last tab closed, moved to another zone, taken out). */
174
+ export declare function pruneGroups(w: LayoutWorkspace): LayoutWorkspace;
125
175
  /**
126
176
  * Move one tab to another zone's cell — joining the zone there as the tab it shows, or making a
127
177
  * new zone in an empty cell. The zone it left keeps its other tabs.
@@ -147,8 +197,9 @@ export declare function addTabToZone(tabs: readonly LayoutTab[], besideId: strin
147
197
  */
148
198
  export declare function showActiveTab(w: LayoutWorkspace): LayoutWorkspace;
149
199
  /**
150
- * Which tab becomes active when `closing` goes: another tab in the same zone if it has one, so
151
- * closing a tab does not also throw you out of the zone; otherwise the first zone left.
200
+ * Which tab becomes active when `closing` goes: the one beside it in the same zone (the next, or
201
+ * the one before when it was last), as a browser does, so closing a tab neither throws you out of
202
+ * the zone nor back to its first tab; otherwise the first zone left.
152
203
  */
153
204
  export declare function nextActiveAfterClose(tabs: readonly LayoutTab[], closing: string): string | undefined;
154
205
  /** The first free spot on the top row, for a zone created without a position of its own. */
@@ -186,6 +237,11 @@ export declare function paneLocation(paneId: string): {
186
237
  wsName: string;
187
238
  tabId: string;
188
239
  } | undefined;
240
+ /**
241
+ * Go to a pane: switch to its workspace and tab, then focus it. The pane only mounts once
242
+ * Terminals has rendered the newly-selected tab, so the focus is retried across a few frames.
243
+ */
244
+ export declare function revealPane(paneId: string): boolean;
189
245
  /** Give a pane real keyboard focus. No-op if it isn't mounted yet — the caller retries. */
190
246
  export declare function focusPane(paneId: string): boolean;
191
247
  export declare const termSpawn: (id: string, cwd: string, cols: number, rows: number) => Promise<void>;
@@ -307,6 +363,8 @@ export declare function paneLines(id: string, max?: number): string[];
307
363
  * PDF plugin), otherwise in this workspace's Editor, at `line` when given.
308
364
  */
309
365
  export declare function openFile(path: string, line?: number): void;
366
+ /** Open a file in this workspace's Editor, whatever opens its type elsewhere (Open with › Editor). */
367
+ export declare function openInEditor(path: string, line?: number): void;
310
368
  export declare function getInstance(id: string): Instance;
311
369
  /**
312
370
  * Wrap pasted text in bracketed-paste markers, so the receiving app can tell one paste from
@@ -384,6 +442,18 @@ export declare function refit(id: string): void;
384
442
  * (Claude, vim) treats it as a repaint. Terminal and shell stay in agreement either way.
385
443
  */
386
444
  export declare function clearPane(id: string): void;
445
+ /**
446
+ * Stop the claude or codex in a pane: asked with Ctrl+C, so it tidies up and prints how to resume,
447
+ * and killed only if it stays (agent_stop in mem.rs), in which case its leftover modes are cleared.
448
+ */
449
+ export declare function stopPaneAgent(id: string): Promise<void>;
450
+ /**
451
+ * Turn off what a killed program left on in the pane's xterm: mouse reporting, focus events,
452
+ * bracketed paste, a hidden cursor. A program that exits turns these off itself; one killed
453
+ * never does, so every mouse move after it is typed into the shell. Only xterm is told (not the
454
+ * PTY), and unlike `term.reset()` the screen and scrollback stay.
455
+ */
456
+ export declare function resetPaneModes(id: string): void;
387
457
  /**
388
458
  * Kill the pane's shell and start a fresh one in the same xterm, keeping the pane id (so the
389
459
  * layout, name and split position survive). Claude is NOT re-resumed: `agentResumed` already
@@ -8,6 +8,8 @@ export declare const normPath: (p: string) => string;
8
8
  export declare const dirName: (p: string) => string;
9
9
  /** A repo as a picker names it: its path under `root`, or its own name when it is not under it. */
10
10
  export declare function repoLabel(root: string, repo: string): string;
11
+ /** The repo a path is in: the deepest of `repos` that holds it (a nested repo before its parent). */
12
+ export declare function repoOf(repos: readonly string[], path: string): string | undefined;
11
13
  export interface ChangeDir<T> {
12
14
  kind: "dir";
13
15
  name: string;
package/validate.mjs ADDED
@@ -0,0 +1,112 @@
1
+ // The checks a package must pass to be published, that need nothing but its files: shared by
2
+ // `deck-plugin publish` (which runs them before anything leaves the machine) and the registry's
3
+ // own validation (deck-registry's tools/validate.mjs, which adds what needs GitHub: who owns it,
4
+ // the release, the rebuild). One copy, so the two cannot disagree about what is valid.
5
+ //
6
+ // Pure: no network, no filesystem.
7
+
8
+ /** Deck's own modules and the view prefixes: a plugin with one of these ids could never load. */
9
+ export const RESERVED = new Set(["terminals", "editor", "docs", "settings", "deck", "module", "plugin", "browser"]);
10
+ /**
11
+ * Packages the registry's rebuild executes. Each must be the real one from npm, since the rebuild
12
+ * runs on a machine that can merge; see the SECURITY note in deck-registry's tools/validate.mjs.
13
+ */
14
+ export const BUILD_RUNS = ["deck-plugin-sdk", "esbuild", "react", "lucide-react", "framer-motion"];
15
+ export const MAX_FILE = 10 * 1024 * 1024, MAX_TOTAL = 15 * 1024 * 1024;
16
+ export const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
17
+ export const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
18
+
19
+ /** A file's name as a release asset: flat, so a language's snippets/x.json is snippets--x.json. */
20
+ export const assetName = (name) => name.replace(/\//g, "--");
21
+
22
+ export function compareVersions(a, b) {
23
+ const x = SEMVER.exec(a), y = SEMVER.exec(b);
24
+ for (let i = 1; i <= 3; i++) if (Number(x[i]) !== Number(y[i])) return Number(x[i]) - Number(y[i]);
25
+ if (x[4] === y[4]) return 0;
26
+ return x[4] === undefined ? 1 : y[4] === undefined ? -1 : x[4] < y[4] ? -1 : 1;
27
+ }
28
+
29
+ /** An id: its shape, and not one of Deck's own. */
30
+ export function checkId(id) {
31
+ const errors = [];
32
+ if (!ID.test(id)) errors.push(`the id \`${id}\` must be lowercase letters, digits and dashes, 40 at most`);
33
+ if (RESERVED.has(id)) errors.push(`\`${id}\` is one of Deck's own names and cannot be a package id`);
34
+ return errors;
35
+ }
36
+
37
+ /** A plugin's plugin.json, for publishing: what the registry shows and what Deck needs. */
38
+ export function checkManifest(m) {
39
+ const errors = [];
40
+ if (typeof m.name !== "string" || !m.name.trim() || m.name.length > 60) errors.push("the name must be 1 to 60 characters");
41
+ if (typeof m.description !== "string" || m.description.trim().length < 10 || m.description.length > 300) {
42
+ errors.push("the description must be 10 to 300 characters");
43
+ }
44
+ if (typeof m.version !== "string" || !SEMVER.test(m.version)) errors.push("`version` must be a version, such as \"1.0.0\"");
45
+ if (typeof m.minDeck !== "string" || !SEMVER.test(m.minDeck)) errors.push("`minDeck` must say which Deck it needs, such as \"1.8.0\"");
46
+ if (m.color !== undefined && !/^#[0-9a-f]{6}$/i.test(m.color)) errors.push("`color` must be #rrggbb");
47
+ return errors;
48
+ }
49
+
50
+ /** The release's files: the allowed names, the required ones, and the size limits. */
51
+ export function checkAssets(kind, assets) {
52
+ const errors = [];
53
+ const ok = (n) => kind === "plugin" ? ["plugin.json", "plugin.js", "mcp.js"].includes(n) : n === "language.json" || /^snippets--[\w.-]+\.json$/.test(n);
54
+ for (const a of assets) if (!ok(a.name)) errors.push(`the release has \`${a.name}\`, which a ${kind} cannot contain`);
55
+ for (const need of kind === "plugin" ? ["plugin.json", "plugin.js"] : ["language.json"]) {
56
+ if (!assets.some((a) => a.name === need)) errors.push(`the release has no \`${need}\``);
57
+ }
58
+ for (const a of assets) if (a.size > MAX_FILE) errors.push(`\`${a.name}\` is ${(a.size / 1048576).toFixed(1)} MB; a file may be 10 MB at most`);
59
+ const total = assets.reduce((n, a) => n + a.size, 0);
60
+ if (total > MAX_TOTAL) errors.push(`the release is ${(total / 1048576).toFixed(1)} MB; a package may be 15 MB at most`);
61
+ for (const a of assets) if ("digest" in a && !/^sha256:[0-9a-f]{64}$/.test(a.digest ?? "")) errors.push(`GitHub gives no sha256 for \`${a.name}\`; upload it again`);
62
+ return errors;
63
+ }
64
+
65
+ /** Whether a lockfile lets `npm ci` install only published npm packages. */
66
+ export function checkLockfile(lock) {
67
+ const errors = [];
68
+ if (!lock || lock.lockfileVersion < 2 || typeof lock.packages !== "object") return ["package-lock.json must be npm 7 or newer (lockfileVersion 2 or 3)"];
69
+ for (const [path, p] of Object.entries(lock.packages)) {
70
+ if (path === "") continue;
71
+ if (p.link) { errors.push(`\`${path}\` is a link to a folder; publish it to npm or bundle it`); continue; }
72
+ if (!p.resolved?.startsWith("https://registry.npmjs.org/") || !/^sha(512|384|256)-/.test(p.integrity ?? "")) {
73
+ errors.push(`\`${path}\` does not come from registry.npmjs.org with an integrity hash`);
74
+ }
75
+ const name = path.replace(/^.*node_modules\//, "");
76
+ const runs = BUILD_RUNS.includes(name) || name.startsWith("@esbuild/");
77
+ if (runs && p.name && p.name !== name) errors.push(`\`${name}\` is an alias of \`${p.name}\`; the build runs it, so it must be the real package`);
78
+ }
79
+ if (!lock.packages["node_modules/deck-plugin-sdk"]) errors.push("the lockfile has no deck-plugin-sdk; a plugin is built with it");
80
+ return errors;
81
+ }
82
+
83
+ /**
84
+ * What a plugin's permissions must be, from its manifest and its bundle (`table` is the SDK's
85
+ * permissions.json): only known names, and covering every bridge call the bundle makes. The shim
86
+ * calls the bridge as `b().fsWrite(`, which minifying keeps, so `().fn(` is the pattern, and a
87
+ * method of the same name on something else (a RegExp's .exec) does not count. Reaching Deck's
88
+ * internals directly, around the bridge, is refused outright.
89
+ */
90
+ export function checkPermissions(declared, bundle, table) {
91
+ const errors = [], notes = [];
92
+ if (!Array.isArray(declared)) {
93
+ return { errors: ['plugin.json must say what it uses: "permissions": [...], or [] when it needs nothing beyond its own data and settings'], notes };
94
+ }
95
+ const unknown = declared.filter((p) => !(p in table.permissions));
96
+ if (unknown.length) errors.push(`unknown permission${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")} (known: ${Object.keys(table.permissions).join(", ")})`);
97
+ if (/__TAURI_INTERNALS__|__TAURI_IPC__|window\.__TAURI__\b/.test(bundle)) {
98
+ errors.push("its bundle reaches Deck's internals directly, around the bridge its permissions apply to");
99
+ }
100
+ const needs = new Map();
101
+ for (const [fn, p] of Object.entries(table.bridge)) {
102
+ if (new RegExp(`\\(\\)\\.${fn}\\(`).test(bundle)) needs.set(p, [...(needs.get(p) ?? []), fn]);
103
+ }
104
+ for (const [mod, p] of Object.entries(table.modules)) {
105
+ if (new RegExp(`__deck\\?\\.${mod}\\b`).test(bundle)) needs.set(p, [...(needs.get(p) ?? []), mod]);
106
+ }
107
+ for (const [p, fns] of needs) {
108
+ if (!declared.includes(p)) errors.push(`it uses ${fns.slice(0, 4).join(", ")}${fns.length > 4 ? ` and ${fns.length - 4} more` : ""}, which ${fns.length > 1 ? "need" : "needs"} the "${p}" permission; add it to "permissions"`);
109
+ }
110
+ if (/\beval\(|new Function\(/.test(bundle)) notes.push("Its bundle uses eval or new Function, which runs code made at run time; a library may do this legitimately.");
111
+ return { errors, notes };
112
+ }