@revenexx/studio-integrations 1.3.0 → 1.4.1

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +457 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CanvasShortcutsDialog.d.vue.ts +9 -0
  4. package/dist/runtime/components/Integrations/CanvasShortcutsDialog.vue +53 -0
  5. package/dist/runtime/components/Integrations/CanvasShortcutsDialog.vue.d.ts +9 -0
  6. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +61 -29
  7. package/dist/runtime/components/Integrations/EditorTip.vue +2 -15
  8. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +10 -0
  9. package/dist/runtime/components/Integrations/NodeConfigField.vue +94 -49
  10. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +10 -0
  11. package/dist/runtime/components/Integrations/NodeInspector.vue +37 -60
  12. package/dist/runtime/components/Integrations/RunDetailPanel.vue +80 -33
  13. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +0 -4
  14. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -6
  15. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +0 -4
  16. package/dist/runtime/components/Integrations/TemplateInstallStep.d.vue.ts +25 -0
  17. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +198 -0
  18. package/dist/runtime/components/Integrations/TemplateInstallStep.vue.d.ts +25 -0
  19. package/dist/runtime/components/Integrations/WorkflowContextMenu.d.vue.ts +2 -2
  20. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +22 -40
  21. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue.d.ts +2 -2
  22. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +94 -65
  23. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +36 -2
  24. package/dist/runtime/components/Integrations/WorkflowEditor.vue +165 -56
  25. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +36 -2
  26. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.d.vue.ts +6 -2
  27. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -106
  28. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue.d.ts +6 -2
  29. package/dist/runtime/components/Integrations/WorkflowsTable.vue +22 -11
  30. package/dist/runtime/composables/useCanvasShortcuts.d.ts +34 -0
  31. package/dist/runtime/composables/useCanvasShortcuts.js +11 -8
  32. package/dist/runtime/composables/useCredentialTypes.js +3 -9
  33. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -14
  34. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  35. package/dist/runtime/composables/useIntegrationsRuns.js +8 -13
  36. package/dist/runtime/composables/useIntegrationsSecrets.js +5 -5
  37. package/dist/runtime/composables/useIntegrationsTemplates.js +5 -8
  38. package/dist/runtime/composables/useIntegrationsTriggers.js +1 -4
  39. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -7
  40. package/dist/runtime/data/canvasShortcuts.d.ts +208 -0
  41. package/dist/runtime/data/canvasShortcuts.js +68 -0
  42. package/dist/runtime/i18n/messages/de.json +30 -16
  43. package/dist/runtime/i18n/messages/en.json +30 -16
  44. package/dist/runtime/pages/integrations/credentials.vue +40 -18
  45. package/dist/runtime/pages/integrations/templates/[slug].vue +22 -132
  46. package/dist/runtime/pages/integrations/workflows/[id].vue +128 -33
  47. package/dist/runtime/utils/apiEnvelope.d.ts +27 -0
  48. package/dist/runtime/utils/apiEnvelope.js +20 -0
  49. package/dist/runtime/utils/apiError.d.ts +35 -8
  50. package/dist/runtime/utils/apiError.js +12 -12
  51. package/dist/runtime/utils/configDefaults.d.ts +18 -0
  52. package/dist/runtime/utils/configDefaults.js +7 -0
  53. package/dist/runtime/utils/errorPathAdvice.d.ts +60 -0
  54. package/dist/runtime/utils/errorPathAdvice.js +19 -0
  55. package/dist/runtime/utils/notify.d.ts +2 -2
  56. package/dist/runtime/utils/runEnvelope.d.ts +12 -0
  57. package/dist/runtime/utils/runEnvelope.js +2 -1
  58. package/dist/runtime/utils/selectOptions.d.ts +48 -0
  59. package/dist/runtime/utils/selectOptions.js +19 -0
  60. package/dist/runtime/utils/shortcutLabel.d.ts +40 -0
  61. package/dist/runtime/utils/shortcutLabel.js +48 -0
  62. package/package.json +1 -1
@@ -0,0 +1,20 @@
1
+ import { IntegrationsApiError } from "./apiError.js";
2
+ export function unwrapRecord(res, ownFields = []) {
3
+ if (res === null || res === void 0) {
4
+ throw new IntegrationsApiError(200, res, "");
5
+ }
6
+ if (typeof res === "object" && "data" in res && !ownFields.some((f) => f in res)) {
7
+ const data = res.data;
8
+ if (data === null || data === void 0) {
9
+ throw new IntegrationsApiError(200, res, "");
10
+ }
11
+ return data;
12
+ }
13
+ return res;
14
+ }
15
+ export function unwrapList(res) {
16
+ if (Array.isArray(res)) return res;
17
+ const data = res?.data;
18
+ if (Array.isArray(data)) return data;
19
+ throw new IntegrationsApiError(200, res, "");
20
+ }
@@ -35,21 +35,48 @@ export declare function apiValidationIssues(err: unknown): {
35
35
  path: string;
36
36
  message: string;
37
37
  }[];
38
+ /** One message the service keyed to a field, as it keyed it. */
39
+ export interface RefusalMessage {
40
+ key: string;
41
+ message: string;
42
+ }
43
+ /**
44
+ * A refusal split by what the form on screen can show of it.
45
+ *
46
+ * `atFields` is what goes under an input, keyed the way the form draws it; `unshown`
47
+ * is everything left over — the reasons that have no input to stand at, in the order
48
+ * the service listed them.
49
+ */
50
+ export interface Refusal {
51
+ atFields: Record<string, string>;
52
+ unshown: RefusalMessage[];
53
+ }
38
54
  /**
39
- * Field-keyed validation messages from a rejection, as `{ field: message }`.
55
+ * A rejection split into the messages a form can show at a field and the messages it
56
+ * cannot, given the keys that form actually draws.
40
57
  *
41
58
  * The counterpart to `apiValidationIssues` above, for the ordinary case that has
42
59
  * nothing to do with the workflow blob: a form field the service refused, named the
43
60
  * way the form names it. Laravel answers a `422` with `errors` keyed by field and a
44
- * list of messages per key; the first message is the one a person reads, so that is
45
- * the one kept.
61
+ * list of messages per key; at a field the first message is the one a person reads,
62
+ * so that is the one kept.
63
+ *
64
+ * **`drawn` is the form's own list, in the order the form draws it** — the name, then
65
+ * the fields of the chosen type, and nothing while those are hidden behind another
66
+ * source. A key absent from it has no input to be shown at, whether because no form
67
+ * draws that field (`config` as a whole, a field the type never declared, a key that
68
+ * is not a field at all) or because this form is currently drawing something else.
69
+ * Both cases go to `unshown`, which is what the surface states below the form: a
70
+ * refusal nobody can see is the same as no answer at all (PO-275, PO-477).
71
+ *
72
+ * A container that is only at fault because one of its members is — `config` beside
73
+ * `config.authKey`, which the service reports as two — is dropped wherever the member
74
+ * is reported too: it says the same thing twice, and the member carries it either way.
46
75
  *
47
- * A form uses this to put the refusal where the operator is looking — "The name has
48
- * already been taken." under the Name field, rather than as a sentence somewhere
49
- * else about a field they then have to find (PO-275). Keys that address the blob are
50
- * left out: those belong to `apiValidationIssues`, which resolves them to nodes.
76
+ * Keys that address the blob are left out: those belong to `apiValidationIssues`,
77
+ * which resolves them to nodes. No credential surface can receive one.
51
78
  */
52
- export declare function apiFieldErrors(err: unknown): Record<string, string>;
79
+ export declare function apiRefusal(err: unknown, drawn: readonly string[]): Refusal;
53
80
  /**
54
81
  * The reason a rejection gives, without the count of everything else it found.
55
82
  *
@@ -18,20 +18,20 @@ export function apiValidationIssues(err) {
18
18
  }
19
19
  return issues;
20
20
  }
21
- export function apiFieldErrors(err) {
22
- if (!(err instanceof IntegrationsApiError) || err.status !== 422) return {};
21
+ export function apiRefusal(err, drawn) {
22
+ const refusal = { atFields: {}, unshown: [] };
23
+ if (!(err instanceof IntegrationsApiError) || err.status !== 422) return refusal;
23
24
  const errors = err.body?.errors;
24
- if (!errors || typeof errors !== "object") return {};
25
- const out = {};
26
- for (const [key, val] of Object.entries(errors)) {
27
- if (key === "blob" || key.startsWith("blob.")) continue;
28
- const first = Array.isArray(val) ? val[0] : val;
29
- if (first !== void 0 && first !== null) out[key] = String(first);
30
- }
31
- for (const key of Object.keys(out)) {
32
- if (Object.keys(out).some((other) => other.startsWith(`${key}.`))) delete out[key];
25
+ if (!errors || typeof errors !== "object") return refusal;
26
+ const keys = Object.keys(errors).filter((key) => key !== "blob" && !key.startsWith("blob."));
27
+ for (const key of keys) {
28
+ if (keys.some((other) => other.startsWith(`${key}.`))) continue;
29
+ const messages = (Array.isArray(errors[key]) ? errors[key] : [errors[key]]).filter((message) => message !== void 0 && message !== null).map(String);
30
+ if (!messages.length) continue;
31
+ if (drawn.includes(key)) refusal.atFields[key] = messages[0];
32
+ else refusal.unshown.push(...messages.map((message) => ({ key, message })));
33
33
  }
34
- return out;
34
+ return refusal;
35
35
  }
36
36
  export function errorReason(err, fallback) {
37
37
  const raw = err?.message?.trim();
@@ -0,0 +1,18 @@
1
+ import type { ConfigField } from '../types/integrations.js';
2
+ /**
3
+ * The config a set of settings starts life with: what each one declares as its
4
+ * default, and nothing else.
5
+ *
6
+ * A setting with no `default` is left OUT rather than written as an empty
7
+ * string, because the two are different answers. `''` is a value — the engine
8
+ * reads it, `??` does not replace it, and a node whose own default would have
9
+ * applied never gets to apply it. An absent key is the absence the node's
10
+ * default answers to.
11
+ *
12
+ * Used in the two places a fresh set of values is made: a node dropped on the
13
+ * canvas (WorkflowEditor), and a row added to a list setting (NodeConfigField).
14
+ * Flat on purpose — it reads `default` at the level it is given and does not
15
+ * descend into `properties` or `items`, so what a newly inserted node carries
16
+ * is exactly what it carried before this became shared.
17
+ */
18
+ export declare function defaultsFor(fields?: ReadonlyArray<Pick<ConfigField, 'key' | 'default'>>): Record<string, unknown>;
@@ -0,0 +1,7 @@
1
+ export function defaultsFor(fields = []) {
2
+ const out = {};
3
+ for (const f of fields ?? []) {
4
+ if (f.default !== void 0) out[f.key] = f.default;
5
+ }
6
+ return out;
7
+ }
@@ -0,0 +1,60 @@
1
+ import type { WorkflowBlob } from '../types/integrations.js';
2
+ /**
3
+ * Nodes that route failures to an error output nothing is connected to
4
+ * (specs/workflow-advice.md).
5
+ *
6
+ * A step that fails on a path its author drew is handled. A step that fails on a
7
+ * path nobody drew is not: the engine ends the run there
8
+ * (`specs/node-failure-handling.md` AC-8 in the service repository), and before
9
+ * it did, the failure vanished and the run reported success. Either way the
10
+ * author never chose it — they simply never saw the port.
11
+ *
12
+ * ## Why this is advice and not a finding
13
+ *
14
+ * The backend's validation findings feed the activation gate AND an
15
+ * unconditional refusal to start a run. A finding here would therefore stop
16
+ * every workflow with an unwired error port from running at all — turning
17
+ * "delivers nothing quietly" into "does not start", for authors who never asked
18
+ * for either. Computing it in the editor makes that impossible rather than
19
+ * merely unlikely: there is no value for a blocking consumer to read.
20
+ *
21
+ * What the client cannot do is be authoritative — the catalogue may not be
22
+ * loaded, and a workflow built through the API never passes here at all. For
23
+ * advice that is the right trade; for a gate it would not be, which is why the
24
+ * gate stays where the manifests are.
25
+ *
26
+ * ## Deliberately not in `validationFindings.ts`
27
+ *
28
+ * Everything there is a backend finding addressed by blob path, and the node
29
+ * card counts those as *incomplete settings*. An unwired port is neither. Sent
30
+ * through `groupFindingsByNode` it would type-check perfectly and quietly
31
+ * corrupt two counts — the banner's and the card badge's.
32
+ */
33
+ export interface ErrorPathAdvice {
34
+ /** Blob node id — not an index, so it survives a node being added or removed. */
35
+ nodeId: string;
36
+ /** The error output with nothing attached. */
37
+ port: string;
38
+ }
39
+ /**
40
+ * Every unwired error output in the blob, in node order.
41
+ *
42
+ * Runs against the blob the author is *editing*, not the saved one, so wiring a
43
+ * port clears its line in the same tick — no save, and no second copy of this
44
+ * rule to decide when the advice went stale.
45
+ *
46
+ * **Resolved against whichever manifest version the catalogue has.**
47
+ * `findManifest` falls back to another version of the same slug when the pinned
48
+ * one is absent, and that fallback is accepted here: a node pinned to a version
49
+ * nobody serves any more is better described by a sibling version than by
50
+ * silence. The cost is real though — where the versions disagree about their
51
+ * ports, the advice describes the sibling's, so a port could be named that the
52
+ * author's own node does not have.
53
+ *
54
+ * **Reads the manifest's static outputs only.** A node whose ports are named at
55
+ * author time (`sourceFromConfig` / `resolveOutputs`) cannot be judged from the
56
+ * declaration alone, so nothing is claimed about it — the same forbearance the
57
+ * engine shows. No shipped node has a dynamic error port today; if one appears,
58
+ * this is where it would silently escape notice.
59
+ */
60
+ export declare function unwiredErrorPorts(blob: Pick<WorkflowBlob, 'nodes' | 'edges'>): ErrorPathAdvice[];
@@ -0,0 +1,19 @@
1
+ import { findManifest } from "../data/integrationNodeCatalog.js";
2
+ export function unwiredErrorPorts(blob) {
3
+ const advice = [];
4
+ for (const node of blob.nodes) {
5
+ const manifest = findManifest(node.nodeSlug, node.nodeVersion);
6
+ if (!manifest) continue;
7
+ const errorPorts = (manifest.outputs ?? []).filter((o) => o.kind === "error" && o.name).map((o) => o.name);
8
+ if (!errorPorts.length) continue;
9
+ if (!isReachable(node, blob, errorPorts)) continue;
10
+ for (const port of errorPorts) {
11
+ const wired = blob.edges.some((e) => e.from.nodeId === node.id && e.from.output === port);
12
+ if (!wired) advice.push({ nodeId: node.id, port });
13
+ }
14
+ }
15
+ return advice;
16
+ }
17
+ function isReachable(node, blob, errorPorts) {
18
+ return blob.edges.some((e) => e.to.nodeId === node.id) || blob.edges.some((e) => e.from.nodeId === node.id && !errorPorts.includes(e.from.output));
19
+ }
@@ -13,10 +13,10 @@ export declare const toast: ((message: string | (() => string | import("vue").Co
13
13
  custom: (component: import("vue").Component, data?: import("vue-sonner").ExternalToast) => string | number;
14
14
  message: (message: string | (() => string | import("vue").Component) | import("vue").Component, data?: import("vue-sonner").ExternalToast) => string | number;
15
15
  promise: <ToastData>(promise: Promise<ToastData> | (() => Promise<ToastData>), data?: ({
16
+ id?: number | string | undefined;
17
+ class?: string | undefined;
16
18
  action?: (import("vue-sonner").Action | import("vue").Component) | undefined;
17
19
  icon?: import("vue").Component | undefined;
18
- class?: string | undefined;
19
- id?: number | string | undefined;
20
20
  position?: ("top-left" | "top-right" | "bottom-left" | "bottom-right" | "top-center" | "bottom-center") | undefined;
21
21
  toasterId?: string | undefined;
22
22
  component?: import("vue").Component | undefined;
@@ -11,6 +11,13 @@ export type RunLike = Record<string, unknown>;
11
11
  * looks like a run (`status`, `temporal_run_id`) is the run, whatever else it has
12
12
  * on it.
13
13
  *
14
+ * The two guards are `RUN_OWN_FIELDS`, exported because `unwrapRecord` reads the
15
+ * same endpoint from `useIntegrationsRuns.ts` and has to apply the same rule —
16
+ * one list, two readers, rather than the two literals that used to sit here
17
+ * (PO-394). What stays different is deliberate: this one answers `null` where
18
+ * `unwrapRecord` throws, because a caller following a run has to count an
19
+ * unusable answer as a failed read rather than have it raised at it.
20
+ *
14
21
  * This is deliberately generous about the *rest* of the shape. It answers "where
15
22
  * is the run in this body", not "is this a usable run" — the caller decides that,
16
23
  * because a caller following a run has to be able to count an unusable answer as
@@ -28,4 +35,9 @@ export declare const RUN_TERMINAL: Set<string>;
28
35
  export declare function isTerminalRun(run: {
29
36
  status?: unknown;
30
37
  } | null | undefined): boolean;
38
+ /**
39
+ * The fields that mark a body as the run itself rather than an envelope around
40
+ * one. A run carrying its own `data` field would otherwise be unwrapped into it.
41
+ */
42
+ export declare const RUN_OWN_FIELDS: readonly ["status", "temporal_run_id"];
31
43
  export declare function unwrapRun(res: unknown): RunLike | null;
@@ -2,10 +2,11 @@ export const RUN_TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "can
2
2
  export function isTerminalRun(run) {
3
3
  return RUN_TERMINAL.has(String(run?.status ?? "").toLowerCase());
4
4
  }
5
+ export const RUN_OWN_FIELDS = ["status", "temporal_run_id"];
5
6
  export function unwrapRun(res) {
6
7
  if (!res || typeof res !== "object") return null;
7
8
  const o = res;
8
- if ("data" in o && o.data && typeof o.data === "object" && !("status" in o) && !("temporal_run_id" in o)) {
9
+ if ("data" in o && o.data && typeof o.data === "object" && !RUN_OWN_FIELDS.some((f) => f in o)) {
9
10
  return o.data;
10
11
  }
11
12
  return o;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Carrying a node's declared options through a select that reserves the empty
3
+ * string for "nothing chosen".
4
+ *
5
+ * "No choice" is a legitimate thing for a node to offer — "as the file came in",
6
+ * "unchanged", "all kinds" — and three node types in two packages arrived at the
7
+ * same way of writing it: an option with an empty value. The select underneath
8
+ * this UI refuses it: the empty string is how it says nothing is selected, so an
9
+ * item may not also carry it. Handed one anyway it throws while the list mounts,
10
+ * and takes the surrounding dialog's dismissal machinery down with it — nothing
11
+ * opened afterwards could be closed again.
12
+ *
13
+ * So every option value is carried under a substitute and swapped back on the way
14
+ * out. **Every** one, rather than only the empty value: a sentinel that stood
15
+ * beside untouched values would be a value a node could declare itself, and
16
+ * declaring it would silently store the empty string instead. Here a declared
17
+ * value always arrives prefixed and the empty one never is, so the two cannot
18
+ * collide whatever a node writes. What the node reads is unchanged.
19
+ */
20
+ /** What the option meaning "no choice" is carried under. */
21
+ export declare const EMPTY_OPTION_VALUE = "option-empty";
22
+ /** Does this list of options ask for the substitute at all? */
23
+ export declare function hasEmptyOption(options: Array<{
24
+ value: unknown;
25
+ }> | undefined): boolean;
26
+ /**
27
+ * An option's value as the select may receive it. That is the guard: the item
28
+ * component cannot be handed an empty value from here, whatever a node declares.
29
+ */
30
+ export declare function toItemValue(value: unknown): string;
31
+ /**
32
+ * The stored value as the select's model — encoded the way the items are, or it
33
+ * matches none of them and the trigger falls back to its placeholder.
34
+ *
35
+ * `null` is read as the empty option alongside the empty string, because that is
36
+ * what the platform hands back for one: Laravel converts an empty string to null
37
+ * anywhere in a request body, so the value chosen here returns as null on the next
38
+ * open and the choice would otherwise be gone. A setting with no value at all is
39
+ * not: nothing is stored for it, and a trigger claiming a choice the config does
40
+ * not hold would be the placeholder's lie in the other direction. A node that
41
+ * offers "no choice" declares it as the field's default, so a step placed on the
42
+ * canvas has the empty string from the start.
43
+ */
44
+ export declare function toModelValue(value: unknown, options: Array<{
45
+ value: unknown;
46
+ }> | undefined): string;
47
+ /** What the select hands back, as the node wants it stored. */
48
+ export declare function fromModelValue(value: string): string;
@@ -0,0 +1,19 @@
1
+ const OPTION_VALUE_PREFIX = "option:";
2
+ export const EMPTY_OPTION_VALUE = "option-empty";
3
+ export function hasEmptyOption(options) {
4
+ return (options ?? []).some((o) => String(o.value) === "");
5
+ }
6
+ export function toItemValue(value) {
7
+ const s = String(value);
8
+ return s === "" ? EMPTY_OPTION_VALUE : OPTION_VALUE_PREFIX + s;
9
+ }
10
+ export function toModelValue(value, options) {
11
+ if (value === "" || value === null || value === void 0) {
12
+ return value !== void 0 && hasEmptyOption(options) ? EMPTY_OPTION_VALUE : "";
13
+ }
14
+ return OPTION_VALUE_PREFIX + String(value);
15
+ }
16
+ export function fromModelValue(value) {
17
+ if (value === EMPTY_OPTION_VALUE) return "";
18
+ return value.startsWith(OPTION_VALUE_PREFIX) ? value.slice(OPTION_VALUE_PREFIX.length) : value;
19
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * How a keyboard shortcut is written for a reader, and which physical modifier
3
+ * `mod` stands for.
4
+ *
5
+ * ONE COPY, deliberately. This table existed twice — in the canvas context
6
+ * menu and in the control pane's tooltip — and the platform test existed a
7
+ * third time inside the shortcuts composable. The two tables had already
8
+ * drifted: the tooltip's knew nothing about `backspace`, `tab` or `space`, so a
9
+ * token it was never handed would have rendered as `BACKSPACE`. A shortcut is
10
+ * about to be shown in a third place (the overview the canvas opens), which is
11
+ * two copies too many.
12
+ *
13
+ * `IS_MAC` reads `navigator.platform`, which is deprecated and reports
14
+ * `MacIntel` for an iPad. It is kept because the replacement
15
+ * (`navigator.userAgentData.platform`) is not in every browser we serve, and
16
+ * because being wrong here costs a wrong glyph rather than a wrong key: the
17
+ * MATCHING side reads the same flag, so a machine we misread is a machine where
18
+ * ⌘ and Ctrl swap together and nothing goes silent.
19
+ */
20
+ export declare const IS_MAC: boolean;
21
+ /**
22
+ * A shortcut token string as a reader sees it: `'mod+enter'` → `⌘↵` on a mac,
23
+ * `Ctrl+↵` elsewhere.
24
+ *
25
+ * Alternatives are not rendered — `'f2|space'` is two ways to press one thing,
26
+ * and a caller that wants both writes both. Pass `isMac` to render for the other
27
+ * platform, which is what the unit suite does.
28
+ */
29
+ /**
30
+ * A token string split into its tokens, where one of the tokens may be the
31
+ * separator itself.
32
+ *
33
+ * `'+'.split('+')` is two empty strings, and `'mod++'` is `['mod', '', '']` — so
34
+ * a pair of empties is a literal `+`. Without this the character disappeared:
35
+ * on a mac, where caps are joined with nothing, `+` rendered as an empty key cap,
36
+ * and elsewhere the join only put it back by accident. Exported so the matcher
37
+ * reads a binding the same way this writes it.
38
+ */
39
+ export declare function shortcutTokens(tokens: string): string[];
40
+ export declare function formatShortcut(tokens: string, isMac?: boolean): string;
@@ -0,0 +1,48 @@
1
+ export const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform ?? "");
2
+ const MAC_TOKENS = {
3
+ mod: "\u2318",
4
+ shift: "\u21E7",
5
+ alt: "\u2325",
6
+ ctrl: "\u2303"
7
+ };
8
+ const OTHER_TOKENS = {
9
+ mod: "Ctrl",
10
+ shift: "Shift",
11
+ alt: "Alt",
12
+ ctrl: "Ctrl"
13
+ };
14
+ const KEY_TOKENS = {
15
+ enter: "\u21B5",
16
+ backspace: "\u232B",
17
+ delete: "Del",
18
+ escape: "Esc",
19
+ tab: "Tab",
20
+ space: "Space",
21
+ arrowup: "\u2191",
22
+ arrowdown: "\u2193",
23
+ arrowleft: "\u2190",
24
+ arrowright: "\u2192"
25
+ };
26
+ export function shortcutTokens(tokens) {
27
+ const parts = tokens.split("+");
28
+ const out = [];
29
+ for (let i = 0; i < parts.length; i++) {
30
+ if (parts[i] === "" && parts[i + 1] === "") {
31
+ out.push("+");
32
+ i++;
33
+ continue;
34
+ }
35
+ if (parts[i] === "") continue;
36
+ out.push(parts[i]);
37
+ }
38
+ return out;
39
+ }
40
+ export function formatShortcut(tokens, isMac = IS_MAC) {
41
+ const mods = isMac ? MAC_TOKENS : OTHER_TOKENS;
42
+ const out = [];
43
+ for (const raw of shortcutTokens(tokens)) {
44
+ const t = raw.toLowerCase();
45
+ out.push(mods[t] ?? KEY_TOKENS[t] ?? (t.length === 1 ? t.toUpperCase() : t.charAt(0).toUpperCase() + t.slice(1)));
46
+ }
47
+ return isMac ? out.join("") : out.join("+");
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
6
6
  "repository": {