@revenexx/studio-integrations 0.5.1 → 0.7.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.
Files changed (171) hide show
  1. package/CHANGELOG.md +428 -0
  2. package/README.md +48 -4
  3. package/dist/module.d.mts +17 -1
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +10 -2
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +43 -41
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +123 -50
  8. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +108 -71
  9. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +12 -15
  10. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
  11. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
  12. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +7 -4
  13. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
  14. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  15. package/dist/runtime/components/Integrations/ExpressionInput.vue +67 -37
  16. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  17. package/dist/runtime/components/Integrations/GroupFrameView.vue +30 -20
  18. package/dist/runtime/components/Integrations/Layout.vue +48 -4
  19. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +23 -23
  20. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +11 -13
  21. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
  22. package/dist/runtime/components/Integrations/NodeConfigField.vue +187 -93
  23. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
  24. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
  25. package/dist/runtime/components/Integrations/NodeInspector.vue +1157 -376
  26. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
  27. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
  28. package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
  29. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
  30. package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
  31. package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
  32. package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
  33. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
  34. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +43 -0
  35. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
  36. package/dist/runtime/components/Integrations/NodePalette.vue +122 -55
  37. package/dist/runtime/components/Integrations/QueryBuilder.vue +46 -38
  38. package/dist/runtime/components/Integrations/RunActionsMenu.vue +38 -12
  39. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  40. package/dist/runtime/components/Integrations/RunPayloadInput.vue +27 -13
  41. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  42. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  43. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  44. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  45. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  46. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  47. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  48. package/dist/runtime/components/Integrations/StickyNoteView.vue +38 -23
  49. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  50. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
  51. package/dist/runtime/components/Integrations/TemplateCard.vue +119 -0
  52. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
  53. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
  54. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +16 -18
  55. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
  56. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  57. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +52 -67
  58. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  59. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
  60. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +29 -11
  61. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
  62. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
  63. package/dist/runtime/components/Integrations/WorkflowEditor.vue +240 -116
  64. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
  65. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +28 -43
  66. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
  67. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +17 -7
  68. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
  69. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
  70. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +198 -56
  71. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
  72. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +56 -59
  73. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +28 -21
  74. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
  75. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +15 -9
  76. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
  77. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +15 -10
  78. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +50 -36
  79. package/dist/runtime/components/Integrations/WorkflowsTable.vue +126 -48
  80. package/dist/runtime/composables/integrationsApi.js +4 -2
  81. package/dist/runtime/composables/useCredentialTypes.js +8 -6
  82. package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
  83. package/dist/runtime/composables/useIntegrationsCredentials.js +10 -8
  84. package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
  85. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  86. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  87. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  88. package/dist/runtime/composables/useIntegrationsNodes.js +7 -5
  89. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  90. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  91. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  92. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  93. package/dist/runtime/composables/useIntegrationsRuns.js +14 -7
  94. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
  95. package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
  96. package/dist/runtime/composables/useIntegrationsSecrets.js +14 -9
  97. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  98. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  99. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  100. package/dist/runtime/composables/useIntegrationsTemplates.js +28 -16
  101. package/dist/runtime/composables/useIntegrationsTriggers.js +10 -8
  102. package/dist/runtime/composables/useIntegrationsWorkflows.js +13 -7
  103. package/dist/runtime/composables/useLoadGate.d.ts +17 -0
  104. package/dist/runtime/composables/useLoadGate.js +8 -0
  105. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  106. package/dist/runtime/data/branchReorder.d.ts +37 -0
  107. package/dist/runtime/data/branchReorder.js +71 -0
  108. package/dist/runtime/data/handleId.d.ts +23 -0
  109. package/dist/runtime/data/handleId.js +6 -0
  110. package/dist/runtime/data/integrationNodeCatalog.d.ts +41 -0
  111. package/dist/runtime/data/integrationNodeCatalog.js +14 -0
  112. package/dist/runtime/data/integrationTemplates.d.ts +48 -0
  113. package/dist/runtime/data/integrationTemplates.js +21 -0
  114. package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
  115. package/dist/runtime/data/nodeIcons.d.ts +2 -0
  116. package/dist/runtime/data/nodeIcons.js +73 -0
  117. package/dist/runtime/data/nodeInputData.d.ts +16 -4
  118. package/dist/runtime/data/nodeInputData.js +4 -0
  119. package/dist/runtime/data/payloadForm.d.ts +9 -2
  120. package/dist/runtime/data/payloadForm.js +29 -4
  121. package/dist/runtime/data/portTone.d.ts +17 -0
  122. package/dist/runtime/data/portTone.js +7 -0
  123. package/dist/runtime/data/webhookAuth.d.ts +18 -0
  124. package/dist/runtime/data/webhookAuth.js +19 -0
  125. package/dist/runtime/data/workflowIconCatalog.js +4 -6
  126. package/dist/runtime/i18n/messages/de.json +1207 -0
  127. package/dist/runtime/i18n/messages/en.json +1207 -0
  128. package/dist/runtime/i18n/translate.d.ts +83 -0
  129. package/dist/runtime/i18n/translate.js +41 -0
  130. package/dist/runtime/pages/integrations/audit.vue +140 -37
  131. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +9 -6
  132. package/dist/runtime/pages/integrations/credentials.vue +180 -64
  133. package/dist/runtime/pages/integrations/index.vue +91 -92
  134. package/dist/runtime/pages/integrations/runs.vue +33 -21
  135. package/dist/runtime/pages/integrations/schedules.vue +88 -23
  136. package/dist/runtime/pages/integrations/secrets.vue +57 -35
  137. package/dist/runtime/pages/integrations/templates/[slug].vue +119 -88
  138. package/dist/runtime/pages/integrations/templates/index.vue +82 -103
  139. package/dist/runtime/pages/integrations/workflows/[id].vue +344 -91
  140. package/dist/runtime/pages/integrations/workflows/index.vue +23 -5
  141. package/dist/runtime/plugins/locale.d.ts +10 -0
  142. package/dist/runtime/plugins/locale.js +6 -0
  143. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  144. package/dist/runtime/plugins/realtime.client.js +4 -0
  145. package/dist/runtime/types/integrations.d.ts +65 -6
  146. package/dist/runtime/utils/apiError.d.ts +47 -0
  147. package/dist/runtime/utils/apiError.js +32 -0
  148. package/dist/runtime/utils/cockpitSources.d.ts +153 -0
  149. package/dist/runtime/utils/cockpitSources.js +14 -0
  150. package/dist/runtime/utils/credentialState.d.ts +24 -1
  151. package/dist/runtime/utils/credentialState.js +26 -11
  152. package/dist/runtime/utils/loadGate.d.ts +61 -0
  153. package/dist/runtime/utils/loadGate.js +22 -0
  154. package/dist/runtime/utils/localize.d.ts +46 -4
  155. package/dist/runtime/utils/localize.js +34 -7
  156. package/dist/runtime/utils/notify.d.ts +59 -0
  157. package/dist/runtime/utils/notify.js +21 -0
  158. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  159. package/dist/runtime/utils/pollBudget.js +17 -0
  160. package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
  161. package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
  162. package/dist/runtime/utils/schedule.d.ts +116 -0
  163. package/dist/runtime/utils/schedule.js +219 -0
  164. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  165. package/dist/runtime/utils/statusStyle.js +9 -1
  166. package/dist/runtime/utils/validationFindings.d.ts +101 -0
  167. package/dist/runtime/utils/validationFindings.js +54 -0
  168. package/dist/runtime/utils/whenIdle.d.ts +42 -0
  169. package/dist/runtime/utils/whenIdle.js +12 -0
  170. package/dist/types.d.mts +2 -6
  171. package/package.json +10 -3
@@ -2,8 +2,8 @@
2
2
  import { computed, markRaw, nextTick, provide, ref, watch } from "vue";
3
3
  import {
4
4
  ConnectionLineType,
5
+ ConnectionMode,
5
6
  MarkerType,
6
- PanOnScrollMode,
7
7
  SelectionMode,
8
8
  VueFlow,
9
9
  useVueFlow
@@ -48,12 +48,20 @@ import { toast } from "vue-sonner";
48
48
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
49
49
  import { layoutWorkflowNodes } from "./workflowLayout";
50
50
  import { TRIGGER_TEMPLATES, findManifest } from "../../data/integrationNodeCatalog";
51
+ import { isTerminalManifest, portsForNode } from "../../data/nodeInputData";
52
+ import { portFromHandleId } from "../../data/handleId";
53
+ import { groupFindingsByNode } from "../../utils/validationFindings";
54
+ import { reorderBranchSubtrees } from "../../data/branchReorder";
55
+ import { findingsByNodeField } from "../../utils/validationFindings";
56
+ import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
51
57
  const props = defineProps({
52
58
  workflow: { type: Object, required: true },
53
59
  runBusy: { type: Boolean, required: false },
54
- runWaiting: { type: Boolean, required: false }
60
+ runWaiting: { type: Boolean, required: false },
61
+ overlayOpen: { type: Boolean, required: false }
55
62
  });
56
63
  const emit = defineEmits(["change", "run", "toggleActive", "openSettings"]);
64
+ const t = useIntegrationsT();
57
65
  const { keys: secretKeys } = useIntegrationsSecrets();
58
66
  const blob = ref(clone(props.workflow.blob));
59
67
  watch(() => props.workflow.id, () => {
@@ -66,7 +74,20 @@ function clone(v) {
66
74
  const vfNodes = ref([]);
67
75
  const vfEdges = ref([]);
68
76
  const flow = useVueFlow();
77
+ const invalidNodeMessages = computed(() => findingsByNodeField(props.workflow.warnings ?? [], props.workflow.blob.nodes));
69
78
  const dynamicOutputs = ref({});
79
+ const invalidNodeFields = computed(() => {
80
+ const out = {};
81
+ for (const g of groupFindingsByNode(props.workflow.warnings ?? [], props.workflow.blob.nodes, () => "")) {
82
+ if (g.nodeId) out[g.nodeId] = g.fields;
83
+ }
84
+ return out;
85
+ });
86
+ let dragInFlight = false;
87
+ watch(() => JSON.stringify(invalidNodeFields.value), () => {
88
+ if (dragInFlight) return;
89
+ syncFromBlob();
90
+ });
70
91
  const dynamicOutputNodes = computed(() => blob.value.nodes.filter((n) => {
71
92
  const m = findManifest(n.nodeSlug, n.nodeVersion);
72
93
  return (m?.outputs ?? []).some((o) => o.resolveOutputs === true);
@@ -79,6 +100,7 @@ function edgeKindFromPort(sourceNodeId, output) {
79
100
  if (output === "true") return "branch-true";
80
101
  if (output === "false") return "branch-false";
81
102
  if (output === "error") return "error";
103
+ if (output === "done") return "success";
82
104
  const portDef = (manifest.outputs ?? []).find((o) => o.name === output);
83
105
  if (portDef) {
84
106
  if (portDef.kind === "error") return "error";
@@ -93,10 +115,10 @@ function edgeKindFromPort(sourceNodeId, output) {
93
115
  }
94
116
  return "default";
95
117
  }
96
- function stripSide(handleId) {
97
- if (!handleId) return null;
98
- const i = handleId.indexOf("__");
99
- return i >= 0 ? handleId.slice(0, i) : handleId;
118
+ function orderedOutputPorts(nodeId) {
119
+ const wn = blob.value.nodes.find((n) => n.id === nodeId);
120
+ if (!wn) return ["default"];
121
+ return portsForNode(wn, findManifest(wn.nodeSlug, wn.nodeVersion), dynamicOutputs.value[nodeId]).map((p) => p.name);
100
122
  }
101
123
  function triggerPosition(id, index) {
102
124
  const saved = blob.value.triggerPositions?.[id];
@@ -106,7 +128,7 @@ function triggerPosition(id, index) {
106
128
  function nodePosition(id) {
107
129
  const wn = blob.value.nodes.find((n) => n.id === id);
108
130
  if (wn) return { x: wn.position?.x ?? 0, y: wn.position?.y ?? 0 };
109
- const ti = blob.value.triggers.findIndex((t) => t.id === id);
131
+ const ti = blob.value.triggers.findIndex((t2) => t2.id === id);
110
132
  if (ti >= 0) return triggerPosition(id, ti);
111
133
  return { x: 0, y: 0 };
112
134
  }
@@ -121,10 +143,6 @@ function stickyArea(s) {
121
143
  }
122
144
  const GROUP_PAD = 24;
123
145
  const GROUP_LABEL_ROOM = 26;
124
- function locText(v) {
125
- if (!v) return "";
126
- return typeof v === "string" ? v : v.en ?? Object.values(v)[0] ?? "";
127
- }
128
146
  function memberDims(id) {
129
147
  const d = flow.findNode(id)?.dimensions;
130
148
  return d && d.width > 0 ? d : { width: 220, height: 64 };
@@ -180,7 +198,7 @@ function ungroup(id) {
180
198
  push();
181
199
  }
182
200
  function createGroupFromSelection(ids) {
183
- const valid = ids.filter((id2) => blob.value.nodes.some((n) => n.id === id2) || blob.value.triggers.some((t) => t.id === id2));
201
+ const valid = ids.filter((id2) => blob.value.nodes.some((n) => n.id === id2) || blob.value.triggers.some((t2) => t2.id === id2));
184
202
  if (valid.length < 1) return;
185
203
  for (const g of blob.value.groups ?? []) {
186
204
  g.nodeIds = g.nodeIds.filter((x) => !valid.includes(x));
@@ -189,7 +207,7 @@ function createGroupFromSelection(ids) {
189
207
  const id = `g-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
190
208
  blob.value.groups = [...blob.value.groups ?? [], {
191
209
  id,
192
- label: `Group ${(blob.value.groups?.length ?? 0) + 1}`,
210
+ label: t("integration.editor.group.defaultLabel", { n: (blob.value.groups?.length ?? 0) + 1 }),
193
211
  nodeIds: valid
194
212
  }];
195
213
  syncFromBlob();
@@ -250,19 +268,23 @@ function syncFromBlob() {
250
268
  onRemove: () => removeBlobNode(s.id)
251
269
  }
252
270
  })),
253
- ...blob.value.triggers.filter((t) => !collapsedBy.has(t.id)).map((t, i) => ({
254
- id: t.id,
271
+ ...blob.value.triggers.filter((t2) => !collapsedBy.has(t2.id)).map((t2, i) => ({
272
+ id: t2.id,
255
273
  type: "trigger",
256
- position: triggerPosition(t.id, i),
257
- class: bornClass(t.id),
258
- data: t
274
+ position: triggerPosition(t2.id, i),
275
+ class: bornClass(t2.id),
276
+ data: t2
259
277
  })),
260
278
  ...blob.value.nodes.filter((n) => !collapsedBy.has(n.id)).map((n) => ({
261
279
  id: n.id,
262
280
  type: "workflow",
263
281
  position: n.position ?? { x: 240, y: 80 },
264
282
  class: bornClass(n.id),
265
- data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id] }
283
+ data: {
284
+ ...n,
285
+ __resolvedOutputs: dynamicOutputs.value[n.id],
286
+ __invalidFields: invalidNodeFields.value[n.id]
287
+ }
266
288
  }))
267
289
  ];
268
290
  const seenEdge = /* @__PURE__ */ new Set();
@@ -285,6 +307,18 @@ function syncFromBlob() {
285
307
  targetHandle: tgtGroup ? "__in__top" : vf.targetHandle
286
308
  });
287
309
  }
310
+ const bySource = /* @__PURE__ */ new Map();
311
+ for (const e of mapped) {
312
+ const list = bySource.get(e.source) ?? [];
313
+ list.push(e);
314
+ bySource.set(e.source, list);
315
+ }
316
+ for (const list of bySource.values()) {
317
+ list.sort((a, b) => (a.data?.portIndex ?? 0) - (b.data?.portIndex ?? 0));
318
+ list.forEach((e, i) => {
319
+ e.data = { ...e.data, laneIndex: i, laneCount: list.length };
320
+ });
321
+ }
288
322
  vfEdges.value = mapped;
289
323
  void nextTick(() => flow.updateNodeInternals(vfNodes.value.map((n) => n.id)));
290
324
  }
@@ -292,9 +326,11 @@ function makeVfEdge(id, sourceId, targetId, output) {
292
326
  const kind = edgeKindFromPort(sourceId, output);
293
327
  const wn = blob.value.nodes.find((n) => n.id === sourceId);
294
328
  const manifest = wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
295
- const isBranchPort = (manifest?.outputs ?? []).some((o) => o.sourceFromConfig) || ["true", "false", "error"].includes(output);
329
+ const isBranchPort = (manifest?.outputs ?? []).some((o) => o.sourceFromConfig) || ["true", "false"].includes(output);
296
330
  const srcSide = pickSourceSide(sourceId, targetId);
297
331
  const tgtSide = oppositeSide(srcSide);
332
+ const order = orderedOutputPorts(sourceId);
333
+ const portIndex = order.indexOf(output);
298
334
  return {
299
335
  id,
300
336
  source: sourceId,
@@ -304,7 +340,8 @@ function makeVfEdge(id, sourceId, targetId, output) {
304
340
  type: "flow",
305
341
  data: {
306
342
  kind,
307
- label: isBranchPort ? output : void 0
343
+ label: isBranchPort ? output : void 0,
344
+ portIndex: portIndex < 0 ? 0 : portIndex
308
345
  },
309
346
  markerEnd: {
310
347
  type: MarkerType.ArrowClosed,
@@ -315,24 +352,21 @@ function makeVfEdge(id, sourceId, targetId, output) {
315
352
  };
316
353
  }
317
354
  function edgeColorByKind(kind) {
318
- switch (kind) {
319
- case "success":
320
- return "#10b981";
321
- case "error":
322
- return "#f43f5e";
323
- case "branch-true":
324
- return "#10b981";
325
- case "branch-false":
326
- return "#f59e0b";
327
- case "branch":
328
- return "#8b5cf6";
329
- default:
330
- return "#94a3b8";
331
- }
355
+ return kind === "error" ? "#cbd5e1" : "#94a3b8";
332
356
  }
333
357
  syncFromBlob();
334
- flow.onPaneReady(() => flow.fitView({ padding: 0.2, maxZoom: 1 }));
358
+ const paneReady = ref(false);
359
+ const pendingReveal = ref(null);
360
+ flow.onPaneReady(() => {
361
+ flow.fitView({ padding: 0.2, maxZoom: 1 });
362
+ paneReady.value = true;
363
+ if (pendingReveal.value) {
364
+ centerOnNode(pendingReveal.value);
365
+ pendingReveal.value = null;
366
+ }
367
+ });
335
368
  flow.onNodeDragStart(({ node }) => {
369
+ dragInFlight = true;
336
370
  if (node.type !== "group") return;
337
371
  const g = blob.value.groups?.find((x) => x.id === node.id);
338
372
  if (!g) return;
@@ -350,6 +384,7 @@ flow.onNodeDrag(({ node }) => {
350
384
  }
351
385
  });
352
386
  function persistDraggedNodes(evt) {
387
+ dragInFlight = false;
353
388
  const moved = evt.nodes?.length ? evt.nodes : [evt.node];
354
389
  for (const n of moved) {
355
390
  const pos = { x: Math.round(n.position.x), y: Math.round(n.position.y) };
@@ -360,7 +395,7 @@ function persistDraggedNodes(evt) {
360
395
  const dy = Math.round(n.position.y - origin.frame.y);
361
396
  for (const [id, memberPos] of Object.entries(origin.members)) {
362
397
  const target = { x: Math.round(memberPos.x + dx), y: Math.round(memberPos.y + dy) };
363
- if (blob.value.triggers.some((t) => t.id === id)) {
398
+ if (blob.value.triggers.some((t2) => t2.id === id)) {
364
399
  blob.value.triggerPositions = { ...blob.value.triggerPositions ?? {}, [id]: target };
365
400
  } else {
366
401
  const wn = blob.value.nodes.find((b) => b.id === id);
@@ -373,7 +408,7 @@ function persistDraggedNodes(evt) {
373
408
  }
374
409
  continue;
375
410
  }
376
- if (blob.value.triggers.some((t) => t.id === n.id)) {
411
+ if (blob.value.triggers.some((t2) => t2.id === n.id)) {
377
412
  blob.value.triggerPositions = { ...blob.value.triggerPositions ?? {}, [n.id]: pos };
378
413
  } else if (blob.value.stickies?.some((s) => s.id === n.id)) {
379
414
  const st = blob.value.stickies.find((s) => s.id === n.id);
@@ -395,7 +430,7 @@ flow.onConnect((conn) => {
395
430
  connectCompleted = true;
396
431
  if (!conn.source || !conn.target) return;
397
432
  const id = `e-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
398
- const output = stripSide(conn.sourceHandle) ?? "default";
433
+ const output = portFromHandleId(conn.sourceHandle) ?? "default";
399
434
  blob.value.edges.push({
400
435
  id,
401
436
  from: { nodeId: conn.source, output },
@@ -409,7 +444,7 @@ let connectStart = null;
409
444
  let connectCompleted = false;
410
445
  flow.onConnectStart(({ nodeId, handleId, handleType }) => {
411
446
  connectCompleted = false;
412
- connectStart = handleType === "source" && nodeId ? { sourceId: nodeId, output: stripSide(handleId) ?? "default" } : null;
447
+ connectStart = handleType === "source" && nodeId ? { sourceId: nodeId, output: portFromHandleId(handleId) ?? "default" } : null;
413
448
  });
414
449
  flow.onConnectEnd((event) => {
415
450
  const start = connectStart;
@@ -422,14 +457,14 @@ flow.onConnectEnd((event) => {
422
457
  pendingConnect.value = { sourceId: start.sourceId, output: start.output, pos: { x: snap(pos.x), y: snap(pos.y) } };
423
458
  pendingSpliceEdgeId.value = null;
424
459
  void focusPalette();
425
- toast.info("Pick a node \u2014 it will be added right there, already connected.");
460
+ toast.info(t("integration.editor.quickConnect.toast"));
426
461
  });
427
462
  flow.onEdgeUpdate(({ edge, connection }) => {
428
463
  if (!connection.source || !connection.target) return;
429
464
  if (connection.source === connection.target) return;
430
465
  const be = blob.value.edges.find((e) => e.id === edge.id);
431
466
  if (!be) return;
432
- be.from = { nodeId: connection.source, output: stripSide(connection.sourceHandle) ?? be.from.output };
467
+ be.from = { nodeId: connection.source, output: portFromHandleId(connection.sourceHandle) ?? be.from.output };
433
468
  be.to = { nodeId: connection.target, input: be.to.input };
434
469
  syncFromBlob();
435
470
  push();
@@ -445,9 +480,11 @@ flow.onEdgesChange((changes) => {
445
480
  if (mutated) push();
446
481
  });
447
482
  flow.onNodesChange((changes) => {
448
- for (const c of changes) {
449
- if (c.type === "remove") removeBlobNode(c.id);
450
- }
483
+ const removed = changes.filter((c) => c.type === "remove").map((c) => c.id);
484
+ if (!removed.length) return;
485
+ void nextTick(() => {
486
+ for (const id of removed) removeBlobNode(id);
487
+ });
451
488
  });
452
489
  function stripFromGroups(drop) {
453
490
  if (!blob.value.groups?.length) return;
@@ -462,7 +499,7 @@ function removeBlobNode(id) {
462
499
  stripFromGroups(/* @__PURE__ */ new Set([id]));
463
500
  blob.value.nodes = blob.value.nodes.filter((n) => n.id !== id);
464
501
  if (blob.value.stickies?.length) blob.value.stickies = blob.value.stickies.filter((s) => s.id !== id);
465
- blob.value.triggers = blob.value.triggers.filter((t) => t.id !== id);
502
+ blob.value.triggers = blob.value.triggers.filter((t2) => t2.id !== id);
466
503
  blob.value.edges = blob.value.edges.filter((e) => e.from.nodeId !== id && e.to.nodeId !== id);
467
504
  if (blob.value.triggerPositions?.[id]) delete blob.value.triggerPositions[id];
468
505
  if (selectedId.value === id) selectedId.value = null;
@@ -493,7 +530,7 @@ function addSticky(at) {
493
530
  id: `s-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
494
531
  // n8n-style starter content: teaches the edit gesture and the supported
495
532
  // markdown by example.
496
- content: "## I'm a note\n**Double-click** to edit me. **Bold**, *italic*, `code`, [links](https://revenexx.com) and\n- lists work",
533
+ content: t("integration.editor.sticky.starterContent"),
497
534
  position: at ? { x: Math.round(at.x), y: Math.round(at.y) } : {
498
535
  x: Math.round(center.x - STICKY_DEFAULT_SIZE.width / 2),
499
536
  y: Math.round(center.y - STICKY_DEFAULT_SIZE.height / 2)
@@ -509,7 +546,7 @@ function removeBlobNodes(ids) {
509
546
  const drop = new Set(ids);
510
547
  stripFromGroups(drop);
511
548
  blob.value.nodes = blob.value.nodes.filter((n) => !drop.has(n.id));
512
- blob.value.triggers = blob.value.triggers.filter((t) => !drop.has(t.id));
549
+ blob.value.triggers = blob.value.triggers.filter((t2) => !drop.has(t2.id));
513
550
  blob.value.edges = blob.value.edges.filter((e) => !drop.has(e.from.nodeId) && !drop.has(e.to.nodeId));
514
551
  if (blob.value.stickies?.length) blob.value.stickies = blob.value.stickies.filter((s) => !drop.has(s.id));
515
552
  for (const id of ids) {
@@ -548,9 +585,18 @@ function duplicateBlobNodes(ids) {
548
585
  });
549
586
  }
550
587
  provide("workflow-node-actions", {
588
+ configure: (id) => {
589
+ renameIntent.value = false;
590
+ selectedId.value = id;
591
+ },
551
592
  duplicate: (id) => duplicateBlobNodes([id]),
552
593
  remove: (id) => removeBlobNode(id)
553
594
  });
595
+ const controlBar = ref(null);
596
+ provide(
597
+ "workflow-canvas-bottom-inset",
598
+ () => controlBar.value ? controlBar.value.offsetHeight + 12 : 0
599
+ );
554
600
  const CLIP_KIND = "revenexx.workflow.nodes";
555
601
  async function copyNodes(ids) {
556
602
  const nodes = blob.value.nodes.filter((n) => ids.includes(n.id));
@@ -559,9 +605,9 @@ async function copyNodes(ids) {
559
605
  const edges = blob.value.edges.filter((e) => inner.has(e.from.nodeId) && inner.has(e.to.nodeId));
560
606
  try {
561
607
  await navigator.clipboard.writeText(JSON.stringify({ kind: CLIP_KIND, version: 1, nodes, edges }, null, 2));
562
- toast.success(nodes.length === 1 ? "Node copied" : `${nodes.length} nodes copied`);
608
+ toast.success(t("integration.editor.clipboard.toast.copied", nodes.length));
563
609
  } catch {
564
- toast.error("Could not write to the clipboard");
610
+ toast.error(t("integration.editor.clipboard.error.write"));
565
611
  }
566
612
  }
567
613
  async function pasteNodes(at) {
@@ -569,18 +615,18 @@ async function pasteNodes(at) {
569
615
  try {
570
616
  text = await navigator.clipboard.readText();
571
617
  } catch {
572
- toast.error("Could not read the clipboard");
618
+ toast.error(t("integration.editor.clipboard.error.read"));
573
619
  return;
574
620
  }
575
621
  let payload;
576
622
  try {
577
623
  payload = JSON.parse(text);
578
624
  } catch {
579
- toast.error("Clipboard does not contain nodes");
625
+ toast.error(t("integration.editor.clipboard.error.empty"));
580
626
  return;
581
627
  }
582
628
  if (payload?.kind !== CLIP_KIND || !Array.isArray(payload.nodes) || !payload.nodes.length) {
583
- toast.error("Clipboard does not contain nodes");
629
+ toast.error(t("integration.editor.clipboard.error.empty"));
584
630
  return;
585
631
  }
586
632
  const idMap = /* @__PURE__ */ new Map();
@@ -696,19 +742,35 @@ function isValidConnection(conn) {
696
742
  const selectedId = ref(null);
697
743
  const selection = computed(() => {
698
744
  if (!selectedId.value) return null;
699
- return blob.value.nodes.find((n) => n.id === selectedId.value) ?? blob.value.triggers.find((t) => t.id === selectedId.value) ?? null;
745
+ return blob.value.nodes.find((n) => n.id === selectedId.value) ?? blob.value.triggers.find((t2) => t2.id === selectedId.value) ?? null;
700
746
  });
701
- flow.onNodeClick((evt) => {
747
+ const selectionIsTrigger = computed(() => !!selectedId.value && blob.value.triggers.some((t2) => t2.id === selectedId.value));
748
+ flow.onNodeDoubleClick((evt) => {
702
749
  if (evt.node.type === "sticky" || evt.node.type === "group") return;
703
750
  selectedId.value = evt.node.id;
704
751
  });
705
752
  flow.onPaneClick(() => {
706
753
  selectedId.value = null;
707
754
  });
708
- function localLabel(v) {
709
- if (!v) return "";
710
- return typeof v === "string" ? v : v.en ?? Object.values(v)[0] ?? "";
755
+ function centerOnNode(id) {
756
+ const vf = vfNodes.value.find((n) => n.id === id);
757
+ if (!vf) return;
758
+ const size = flow.findNode(id)?.dimensions;
759
+ void flow.setCenter(
760
+ vf.position.x + (size?.width ?? 220) / 2,
761
+ vf.position.y + (size?.height ?? 76) / 2,
762
+ { zoom: Math.max(flow.viewport.value?.zoom ?? 1, 0.75), duration: 300 }
763
+ );
764
+ }
765
+ function revealNode(id) {
766
+ if (paneReady.value) centerOnNode(id);
767
+ else pendingReveal.value = id;
768
+ if (blob.value.nodes.some((n) => n.id === id) || blob.value.triggers.some((t2) => t2.id === id)) {
769
+ renameIntent.value = false;
770
+ selectedId.value = id;
771
+ }
711
772
  }
773
+ defineExpose({ revealNode });
712
774
  const selectedUpstreamOutputs = computed(() => {
713
775
  const id = selectedId.value;
714
776
  if (!id) return [];
@@ -720,7 +782,9 @@ const selectedUpstreamOutputs = computed(() => {
720
782
  const portDef = (findManifest(source.nodeSlug, source.nodeVersion)?.outputs ?? []).find((o) => o.name === e.from.output);
721
783
  return [{
722
784
  nodeId: source.id,
723
- nodeName: localLabel(source.name) || source.nodeSlug,
785
+ // A node's own name is text the user typed — never trimmed, never read
786
+ // as JSON. The manifest strings below it get the normalising resolver.
787
+ nodeName: resolveDisplayName(source.name) || source.nodeSlug,
724
788
  output: e.from.output,
725
789
  fields: portDef?.fields
726
790
  }];
@@ -728,7 +792,7 @@ const selectedUpstreamOutputs = computed(() => {
728
792
  });
729
793
  function applyInspectorUpdate(next) {
730
794
  const id = next.id;
731
- const triggerIdx = blob.value.triggers.findIndex((t) => t.id === id);
795
+ const triggerIdx = blob.value.triggers.findIndex((t2) => t2.id === id);
732
796
  if (triggerIdx >= 0) {
733
797
  blob.value.triggers[triggerIdx] = next;
734
798
  } else {
@@ -801,7 +865,7 @@ function addFromPayload(payload, pos) {
801
865
  const id2 = `g-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
802
866
  blob.value.groups = [...blob.value.groups ?? [], {
803
867
  id: id2,
804
- label: `Group ${(blob.value.groups?.length ?? 0) + 1}`,
868
+ label: t("integration.editor.group.defaultLabel", { n: (blob.value.groups?.length ?? 0) + 1 }),
805
869
  nodeIds: [],
806
870
  rect: { x: at.x, y: at.y, ...EMPTY_GROUP_SIZE }
807
871
  }];
@@ -811,7 +875,7 @@ function addFromPayload(payload, pos) {
811
875
  return;
812
876
  }
813
877
  if (payload.kind === "trigger") {
814
- const tpl = TRIGGER_TEMPLATES.find((t) => t.type === payload.type);
878
+ const tpl = TRIGGER_TEMPLATES.find((t2) => t2.type === payload.type);
815
879
  if (!tpl) return;
816
880
  const id2 = crypto.randomUUID();
817
881
  blob.value.triggers.push({ id: id2, type: tpl.type, config: { ...tpl.defaultConfig } });
@@ -819,7 +883,6 @@ function addFromPayload(payload, pos) {
819
883
  adoptIntoGroupAt(id2, at);
820
884
  markBorn(id2);
821
885
  syncFromBlob();
822
- selectedId.value = id2;
823
886
  push();
824
887
  return;
825
888
  }
@@ -836,7 +899,6 @@ function addFromPayload(payload, pos) {
836
899
  adoptIntoGroupAt(id, at);
837
900
  markBorn(id);
838
901
  syncFromBlob();
839
- selectedId.value = id;
840
902
  push();
841
903
  }
842
904
  function onDrop(ev) {
@@ -871,7 +933,6 @@ function onPaletteInsert(payload) {
871
933
  });
872
934
  markBorn(id);
873
935
  syncFromBlob();
874
- selectedId.value = id;
875
936
  push();
876
937
  return;
877
938
  }
@@ -892,7 +953,7 @@ provide("workflow-edge-actions", {
892
953
  if (!blob.value.edges.some((e) => e.id === edgeId)) return;
893
954
  pendingSpliceEdgeId.value = edgeId;
894
955
  void focusPalette();
895
- toast.info("Pick a node from the palette \u2014 it will be inserted into the connection.");
956
+ toast.info(t("integration.editor.splice.toast"));
896
957
  }
897
958
  });
898
959
  function primaryOutputName(slug, version) {
@@ -903,6 +964,10 @@ function spliceIntoEdge(edgeId, payload) {
903
964
  const edge = blob.value.edges.find((e) => e.id === edgeId);
904
965
  const manifest = findManifest(payload.slug, payload.version);
905
966
  if (!edge || !manifest) return;
967
+ if (isTerminalManifest(manifest)) {
968
+ toast.error(t("integration.editor.splice.error.terminal", { name: resolveLocalized(manifest.name) || manifest.slug }));
969
+ return;
970
+ }
906
971
  const a = nodePosition(edge.from.nodeId);
907
972
  const b = nodePosition(edge.to.nodeId);
908
973
  const id = `n-${Date.now()}`;
@@ -921,7 +986,6 @@ function spliceIntoEdge(edgeId, payload) {
921
986
  );
922
987
  markBorn(id);
923
988
  syncFromBlob();
924
- selectedId.value = id;
925
989
  push();
926
990
  }
927
991
  function defaultConfigFor(fields = []) {
@@ -935,6 +999,34 @@ function nodeDims(id) {
935
999
  const d = flow.findNode(id)?.dimensions;
936
1000
  return d && d.width > 0 ? d : { width: 220, height: 64 };
937
1001
  }
1002
+ function reorderBranchesByHandle(laid, memberToGroup) {
1003
+ const posById = new Map(laid.map((n) => [n.id, n]));
1004
+ const nodes = /* @__PURE__ */ new Map();
1005
+ for (const n of laid) {
1006
+ if (!memberToGroup.has(n.id)) nodes.set(n.id, { x: n.position.x });
1007
+ }
1008
+ const portOrderCache = /* @__PURE__ */ new Map();
1009
+ const portsFor = (id) => {
1010
+ let o = portOrderCache.get(id);
1011
+ if (!o) {
1012
+ o = orderedOutputPorts(id);
1013
+ portOrderCache.set(id, o);
1014
+ }
1015
+ return o;
1016
+ };
1017
+ const edges = [];
1018
+ for (const e of vfEdges.value) {
1019
+ if (!nodes.has(e.source) || !nodes.has(e.target)) continue;
1020
+ const port = portFromHandleId(e.sourceHandle) ?? "default";
1021
+ const order = portsFor(e.source);
1022
+ const idx = order.indexOf(port);
1023
+ edges.push({ source: e.source, target: e.target, portIndex: idx < 0 ? order.length : idx });
1024
+ }
1025
+ for (const [id, x] of reorderBranchSubtrees(nodes, edges)) {
1026
+ const n = posById.get(id);
1027
+ n.position = { x, y: n.position.y };
1028
+ }
1029
+ }
938
1030
  async function tidyUp() {
939
1031
  const containment = /* @__PURE__ */ new Map();
940
1032
  for (const s of blob.value.stickies ?? []) {
@@ -1014,6 +1106,7 @@ async function tidyUp() {
1014
1106
  if (!inner) return n;
1015
1107
  return { ...n, position: { x: anchor.x + GROUP_PAD + inner.position.x, y: anchor.y + GROUP_PAD + GROUP_LABEL_ROOM + inner.position.y } };
1016
1108
  });
1109
+ reorderBranchesByHandle(laid, memberToGroup);
1017
1110
  for (const n of laid) {
1018
1111
  const pos = { x: Math.round(n.position.x), y: Math.round(n.position.y) };
1019
1112
  if (n.type === "workflow") {
@@ -1120,6 +1213,7 @@ async function focusPalette() {
1120
1213
  paletteRef.value?.focusSearch();
1121
1214
  }
1122
1215
  const lightboxOpen = computed(() => !!selection.value);
1216
+ const canvasHasKeyboard = computed(() => !lightboxOpen.value && !variablesOpen.value && !props.overlayOpen);
1123
1217
  const singleSelected = computed(() => flow.getSelectedNodes.value.length === 1 ? flow.getSelectedNodes.value[0] : null);
1124
1218
  useCanvasShortcuts(() => ({
1125
1219
  "enter": {
@@ -1181,7 +1275,7 @@ useCanvasShortcuts(() => ({
1181
1275
  pendingConnect.value = null;
1182
1276
  flow.removeSelectedElements();
1183
1277
  }
1184
- }), { disabled: lightboxOpen });
1278
+ }), { disabled: () => !canvasHasKeyboard.value });
1185
1279
  const nodeTypes = {
1186
1280
  workflow: markRaw(WorkflowNodeView),
1187
1281
  trigger: markRaw(WorkflowTriggerView),
@@ -1207,7 +1301,7 @@ const edgeTypes = {
1207
1301
  :kind="menu.kind"
1208
1302
  :selection-count="flow.getSelectedNodes.value.length"
1209
1303
  :total-nodes="vfNodes.length"
1210
- :target-is-trigger="!!menu.targetId && blob.triggers.some((t) => t.id === menu.targetId)"
1304
+ :target-is-trigger="!!menu.targetId && blob.triggers.some((t2) => t2.id === menu.targetId)"
1211
1305
  :run-busy="runBusy"
1212
1306
  @action="onMenuAction"
1213
1307
  >
@@ -1219,15 +1313,15 @@ const edgeTypes = {
1219
1313
  :default-edge-options="{ type: 'flow' }"
1220
1314
  :connection-line-type="ConnectionLineType.Step"
1221
1315
  :connection-line-style="{ stroke: '#94a3b8', strokeWidth: 1.75, strokeDasharray: '4 4' }"
1316
+ :connection-mode="ConnectionMode.Strict"
1222
1317
  :is-valid-connection="isValidConnection"
1223
1318
  :snap-to-grid="true"
1224
1319
  :snap-grid="[20, 20]"
1225
1320
  :selection-mode="SelectionMode.Partial"
1226
1321
  :selection-key-code="pointerMode === 'select' ? true : 'Shift'"
1227
1322
  :pan-on-drag="pointerMode === 'hand' ? true : [1]"
1228
- :pan-on-scroll="true"
1229
- :pan-on-scroll-mode="PanOnScrollMode.Free"
1230
- :delete-key-code="['Backspace', 'Delete']"
1323
+ :zoom-on-double-click="false"
1324
+ :delete-key-code="canvasHasKeyboard ? ['Backspace', 'Delete'] : null"
1231
1325
  :edges-updatable="true"
1232
1326
  :min-zoom="0.25"
1233
1327
  :max-zoom="4"
@@ -1260,7 +1354,7 @@ const edgeTypes = {
1260
1354
  class="absolute -top-0.5 left-1/2 -translate-x-1/2 -translate-y-full rounded-md px-1.5 py-0.5 text-xs font-medium text-white shadow-sm"
1261
1355
  :class="nodeDrag.dragging.value?.kind === 'trigger' ? 'bg-emerald-500' : nodeDrag.dragging.value?.kind === 'group' ? 'bg-slate-500' : 'bg-violet-500'"
1262
1356
  >
1263
- Drop to add
1357
+ {{ t("integration.editor.dropGhost.label") }}
1264
1358
  </span>
1265
1359
  </div>
1266
1360
  </Transition>
@@ -1313,11 +1407,11 @@ const edgeTypes = {
1313
1407
  <button
1314
1408
  type="button"
1315
1409
  class="flex h-9 items-center gap-1.5 rounded-lg bg-white px-3 text-sm font-medium transition hover:bg-muted/75 hover:cursor-pointer"
1316
- title="Expand the node palette"
1410
+ :title="t('integration.editor.palette.action.expand.title')"
1317
1411
  @click="void focusPalette()"
1318
1412
  >
1319
1413
  <Plus :size="14" class="text-emerald-600" />
1320
- Add node
1414
+ {{ t("integration.editor.palette.title") }}
1321
1415
  </button>
1322
1416
  </div>
1323
1417
  </div>
@@ -1336,29 +1430,29 @@ const edgeTypes = {
1336
1430
  @pointerdown="palette.startDrag($event, paletteShellRef)"
1337
1431
  >
1338
1432
  <GripVertical :size="14" class="shrink-0 text-muted-foreground/60" />
1339
- <span class="min-w-0 flex-1 truncate text-sm font-medium">Add node</span>
1433
+ <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ t("integration.editor.palette.title") }}</span>
1340
1434
  <DropdownMenu>
1341
1435
  <DropdownMenuTrigger as-child>
1342
1436
  <button
1343
1437
  type="button"
1344
1438
  class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1345
- title="Palette placement"
1439
+ :title="t('integration.editor.palette.action.placement.title')"
1346
1440
  >
1347
1441
  <EllipsisVertical :size="14" />
1348
1442
  </button>
1349
1443
  </DropdownMenuTrigger>
1350
1444
  <DropdownMenuContent align="end" class="w-36 rounded-lg border border-black/[0.06] bg-white p-1 shadow-lg">
1351
- <DropdownMenuItem class="rounded-md text-sm" @select="palette.dock('left')">Dock left</DropdownMenuItem>
1352
- <DropdownMenuItem class="rounded-md text-sm" @select="palette.dock('right')">Dock right</DropdownMenuItem>
1353
- <DropdownMenuItem class="rounded-md text-sm" @select="palette.floatHere(paletteShellRef)">Float here</DropdownMenuItem>
1445
+ <DropdownMenuItem class="rounded-md text-sm" @select="palette.dock('left')">{{ t("integration.editor.palette.action.dockLeft") }}</DropdownMenuItem>
1446
+ <DropdownMenuItem class="rounded-md text-sm" @select="palette.dock('right')">{{ t("integration.editor.palette.action.dockRight") }}</DropdownMenuItem>
1447
+ <DropdownMenuItem class="rounded-md text-sm" @select="palette.floatHere(paletteShellRef)">{{ t("integration.editor.panel.action.floatHere") }}</DropdownMenuItem>
1354
1448
  <DropdownMenuSeparator class="my-1 h-px bg-black/[0.06]" />
1355
- <DropdownMenuItem class="rounded-md text-sm" @select="palette.setCollapsed(true)">Collapse</DropdownMenuItem>
1449
+ <DropdownMenuItem class="rounded-md text-sm" @select="palette.setCollapsed(true)">{{ t("integration.editor.panel.action.collapse") }}</DropdownMenuItem>
1356
1450
  </DropdownMenuContent>
1357
1451
  </DropdownMenu>
1358
1452
  <button
1359
1453
  type="button"
1360
1454
  class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1361
- title="Collapse the palette"
1455
+ :title="t('integration.editor.palette.action.collapse.title')"
1362
1456
  @click="palette.setCollapsed(true)"
1363
1457
  >
1364
1458
  <Minus :size="14" />
@@ -1400,11 +1494,11 @@ const edgeTypes = {
1400
1494
  <button
1401
1495
  type="button"
1402
1496
  class="flex h-9 items-center gap-1.5 rounded-lg bg-white px-3 text-sm font-medium transition hover:bg-muted/75 hover:cursor-pointer"
1403
- title="Expand the minimap"
1497
+ :title="t('integration.editor.minimap.action.expand.title')"
1404
1498
  @click="minimapLayout.setCollapsed(false)"
1405
1499
  >
1406
1500
  <MapIcon :size="14" class="text-emerald-600" />
1407
- Map
1501
+ {{ t("integration.editor.minimap.title") }}
1408
1502
  </button>
1409
1503
  </div>
1410
1504
  </div>
@@ -1423,31 +1517,31 @@ const edgeTypes = {
1423
1517
  @pointerdown="minimapLayout.startDrag($event, minimapShellRef)"
1424
1518
  >
1425
1519
  <GripVertical :size="14" class="shrink-0 text-muted-foreground/60" />
1426
- <span class="min-w-0 flex-1 truncate text-sm font-medium">Map</span>
1520
+ <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ t("integration.editor.minimap.title") }}</span>
1427
1521
  <DropdownMenu>
1428
1522
  <DropdownMenuTrigger as-child>
1429
1523
  <button
1430
1524
  type="button"
1431
1525
  class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1432
- title="Minimap placement"
1526
+ :title="t('integration.editor.minimap.action.placement.title')"
1433
1527
  >
1434
1528
  <EllipsisVertical :size="14" />
1435
1529
  </button>
1436
1530
  </DropdownMenuTrigger>
1437
1531
  <DropdownMenuContent align="end" class="w-40 rounded-lg border border-black/[0.06] bg-white p-1 shadow-lg">
1438
- <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('top-left')">Top left</DropdownMenuItem>
1439
- <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('top-right')">Top right</DropdownMenuItem>
1440
- <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('bottom-left')">Bottom left</DropdownMenuItem>
1441
- <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('bottom-right')">Bottom right</DropdownMenuItem>
1442
- <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.floatHere(minimapShellRef)">Float here</DropdownMenuItem>
1532
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('top-left')">{{ t("integration.editor.minimap.action.topLeft") }}</DropdownMenuItem>
1533
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('top-right')">{{ t("integration.editor.minimap.action.topRight") }}</DropdownMenuItem>
1534
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('bottom-left')">{{ t("integration.editor.minimap.action.bottomLeft") }}</DropdownMenuItem>
1535
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.dock('bottom-right')">{{ t("integration.editor.minimap.action.bottomRight") }}</DropdownMenuItem>
1536
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.floatHere(minimapShellRef)">{{ t("integration.editor.panel.action.floatHere") }}</DropdownMenuItem>
1443
1537
  <DropdownMenuSeparator class="my-1 h-px bg-black/[0.06]" />
1444
- <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.setCollapsed(true)">Collapse</DropdownMenuItem>
1538
+ <DropdownMenuItem class="rounded-md text-sm" @select="minimapLayout.setCollapsed(true)">{{ t("integration.editor.panel.action.collapse") }}</DropdownMenuItem>
1445
1539
  </DropdownMenuContent>
1446
1540
  </DropdownMenu>
1447
1541
  <button
1448
1542
  type="button"
1449
1543
  class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1450
- title="Collapse the minimap"
1544
+ :title="t('integration.editor.minimap.action.collapse.title')"
1451
1545
  @click="minimapLayout.setCollapsed(true)"
1452
1546
  >
1453
1547
  <Minus :size="14" />
@@ -1466,14 +1560,14 @@ const edgeTypes = {
1466
1560
  </Transition>
1467
1561
 
1468
1562
  <!-- Floating control pane — bottom center. -->
1469
- <div class="absolute bottom-3 left-1/2 z-10 -translate-x-1/2">
1563
+ <div ref="controlBar" class="absolute bottom-3 left-1/2 z-10 -translate-x-1/2">
1470
1564
  <TooltipProvider :delay-duration="250">
1471
1565
  <div class="rounded-lg border border-gray-400/10 outline-1 outline-white/30 backdrop-blur-sm">
1472
1566
  <div class="flex items-center gap-0.5 whitespace-nowrap border border-white bg-white rounded-lg bg-gradient-to-br from-white via-violet-50/40 to-white px-2 py-1">
1473
1567
  <!-- Smart Run: the page decides what "run" means — workflows that
1474
1568
  consume trigger.payload open the payload dialog, payload-free
1475
1569
  ones fire immediately. One button either way. -->
1476
- <IntegrationsEditorTip :label="runWaiting ? 'The bundle is still building \u2014 the run starts when it is ready' : 'Run workflow'" shortcut="mod+enter">
1570
+ <IntegrationsEditorTip :label="runWaiting ? t('integration.editor.tool.run.tooltipWaiting') : t('integration.editor.tool.run.tooltip')" shortcut="mod+enter">
1477
1571
  <button
1478
1572
  type="button"
1479
1573
  class="relative flex h-8 items-center gap-1.5 rounded-lg bg-gradient-to-b from-emerald-500 to-emerald-600 px-3.5 text-sm font-medium text-white transition after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_1px_0_rgba(255,255,255,0.3),inset_0_-2px_0_rgba(0,0,0,0.25)] hover:from-emerald-600 hover:to-emerald-600 active:translate-y-px disabled:opacity-60"
@@ -1482,16 +1576,20 @@ const edgeTypes = {
1482
1576
  >
1483
1577
  <Loader2 v-if="runBusy" :size="14" class="animate-spin" />
1484
1578
  <Play v-else :size="14" />
1485
- {{ runWaiting ? "Waiting for build\u2026" : "Run" }}
1579
+ {{ runWaiting ? t("integration.editor.run.waitingForBuild") : t("integration.editor.run.button") }}
1486
1580
  </button>
1487
1581
  </IntegrationsEditorTip>
1488
1582
 
1489
1583
  <div class="mx-1 h-5 w-px bg-black/10" />
1490
1584
 
1491
1585
  <div class="flex items-center gap-0.5 rounded-lg bg-black/[0.05] p-0.5 shadow-[inset_0_1px_2px_rgba(15,23,42,0.12)]">
1492
- <IntegrationsEditorTip label="Select" shortcut="v">
1586
+ <!-- Icon-only, and the tooltip is not an accessible name: each one
1587
+ says what it does itself, and whether it is the active mode. -->
1588
+ <IntegrationsEditorTip :label="t('integration.editor.tool.select')" shortcut="v">
1493
1589
  <button
1494
1590
  type="button"
1591
+ :aria-label="t('integration.editor.tool.select')"
1592
+ :aria-pressed="pointerMode === 'select'"
1495
1593
  class="flex size-7 items-center justify-center rounded-lg transition"
1496
1594
  :class="pointerMode === 'select' ? 'bg-gradient-to-b from-white to-gray-50 text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.9)]' : 'text-muted-foreground hover:text-foreground'"
1497
1595
  @click="setPointerMode('select')"
@@ -1499,9 +1597,11 @@ const edgeTypes = {
1499
1597
  <MousePointer2 :size="14" />
1500
1598
  </button>
1501
1599
  </IntegrationsEditorTip>
1502
- <IntegrationsEditorTip label="Hand (pan)" shortcut="h">
1600
+ <IntegrationsEditorTip :label="t('integration.editor.tool.hand')" shortcut="h">
1503
1601
  <button
1504
1602
  type="button"
1603
+ :aria-label="t('integration.editor.tool.hand')"
1604
+ :aria-pressed="pointerMode === 'hand'"
1505
1605
  class="flex size-7 items-center justify-center rounded-lg transition"
1506
1606
  :class="pointerMode === 'hand' ? 'bg-gradient-to-b from-white to-gray-50 text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.9)]' : 'text-muted-foreground hover:text-foreground'"
1507
1607
  @click="setPointerMode('hand')"
@@ -1511,73 +1611,74 @@ const edgeTypes = {
1511
1611
  </IntegrationsEditorTip>
1512
1612
  </div>
1513
1613
 
1514
- <IntegrationsEditorTip label="Tidy up" shortcut="shift+alt+t">
1614
+ <IntegrationsEditorTip :label="t('integration.editor.tool.tidyUp')" shortcut="shift+alt+t">
1515
1615
  <button
1516
1616
  type="button"
1517
1617
  class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1518
1618
  @click="tidyUp"
1519
1619
  >
1520
1620
  <LayoutGrid :size="13" />
1521
- Tidy up
1621
+ {{ t("integration.editor.tool.tidyUp") }}
1522
1622
  </button>
1523
1623
  </IntegrationsEditorTip>
1524
- <IntegrationsEditorTip label="Fit view" shortcut="1">
1624
+ <IntegrationsEditorTip :label="t('integration.editor.tool.fitView.tooltip')" shortcut="1">
1525
1625
  <button
1526
1626
  type="button"
1527
1627
  class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1528
1628
  @click="flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 })"
1529
1629
  >
1530
1630
  <Sparkles :size="13" />
1531
- Fit
1631
+ {{ t("integration.editor.tool.fitView.label") }}
1532
1632
  </button>
1533
1633
  </IntegrationsEditorTip>
1534
- <IntegrationsEditorTip label="Add sticky note" shortcut="shift+s">
1634
+ <IntegrationsEditorTip :label="t('integration.editor.tool.addSticky.tooltip')" shortcut="shift+s">
1535
1635
  <button
1536
1636
  type="button"
1537
1637
  class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1538
1638
  @click="addSticky()"
1539
1639
  >
1540
1640
  <StickyNote :size="13" />
1541
- Note
1641
+ {{ t("integration.editor.tool.addSticky.label") }}
1542
1642
  </button>
1543
1643
  </IntegrationsEditorTip>
1544
- <IntegrationsEditorTip label="Add group frame — nodes dropped inside join it">
1644
+ <IntegrationsEditorTip :label="t('integration.editor.tool.addGroup.tooltip')">
1545
1645
  <button
1546
1646
  type="button"
1547
1647
  class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1548
1648
  @click="addGroupFrame"
1549
1649
  >
1550
1650
  <GroupIcon :size="13" />
1551
- Group
1651
+ {{ t("integration.editor.tool.addGroup.label") }}
1552
1652
  </button>
1553
1653
  </IntegrationsEditorTip>
1554
- <IntegrationsEditorTip label="Workflow variables" shortcut="shift+v">
1654
+ <IntegrationsEditorTip :label="t('integration.editor.tool.variables.tooltip')" shortcut="shift+v">
1555
1655
  <button
1556
1656
  type="button"
1557
1657
  class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1558
1658
  @click="variablesOpen = true"
1559
1659
  >
1560
1660
  <Braces :size="13" />
1561
- Variables
1661
+ {{ t("integration.editor.tool.variables.label") }}
1562
1662
  <span v-if="variableCount" class="rounded-lg bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">{{ variableCount }}</span>
1563
1663
  </button>
1564
1664
  </IntegrationsEditorTip>
1565
- <IntegrationsEditorTip label="Workflow settings" shortcut="shift+alt+s">
1665
+ <IntegrationsEditorTip :label="t('integration.editor.tool.settings.tooltip')" shortcut="shift+alt+s">
1566
1666
  <button
1567
1667
  type="button"
1568
1668
  class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1569
1669
  @click="emit('openSettings')"
1570
1670
  >
1571
1671
  <Settings2 :size="13" />
1572
- Settings
1672
+ {{ t("integration.editor.tool.settings.label") }}
1573
1673
  </button>
1574
1674
  </IntegrationsEditorTip>
1575
1675
 
1576
1676
  <div class="mx-1 h-5 w-px bg-black/10" />
1577
1677
 
1578
- <IntegrationsEditorTip label="Zoom out" shortcut="-">
1678
+ <IntegrationsEditorTip :label="t('integration.editor.tool.zoomOut')" shortcut="-">
1579
1679
  <button
1580
1680
  type="button"
1681
+ :aria-label="t('integration.editor.tool.zoomOut')"
1581
1682
  class="flex size-8 items-center justify-center rounded-lg text-foreground transition hover:bg-black/[0.04]"
1582
1683
  @click="flow.zoomOut({ duration: 150 })"
1583
1684
  >
@@ -1587,12 +1688,17 @@ const edgeTypes = {
1587
1688
  <!-- Tooltip sits on a wrapper span so its as-child merge never
1588
1689
  touches the dropdown trigger (merging both onto one button
1589
1690
  swallowed the trigger's pointerdown and the menu never opened). -->
1590
- <IntegrationsEditorTip label="Zoom level, 0 resets to 100%" shortcut="0">
1691
+ <IntegrationsEditorTip :label="t('integration.editor.tool.zoomLevel.tooltip')" shortcut="0">
1591
1692
  <span class="inline-flex">
1592
1693
  <DropdownMenu v-model:open="zoomMenuOpen">
1593
1694
  <DropdownMenuTrigger as-child>
1695
+ <!-- The label carries the percentage because it replaces the
1696
+ button's text as its accessible name: a bare "Zoom level"
1697
+ would leave screen reader users with the one thing sighted
1698
+ users do not need told and without the value they do. -->
1594
1699
  <button
1595
1700
  type="button"
1701
+ :aria-label="t('integration.editor.tool.zoomLevel.aria', { pct: zoomPct })"
1596
1702
  class="w-12 rounded-lg py-1 text-center text-sm tabular-nums text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground"
1597
1703
  >
1598
1704
  {{ zoomPct }}
@@ -1604,6 +1710,7 @@ const edgeTypes = {
1604
1710
  v-model="zoomInputText"
1605
1711
  type="text"
1606
1712
  inputmode="numeric"
1713
+ :aria-label="t('integration.editor.tool.zoomLevel.inputAria')"
1607
1714
  class="h-6 w-full rounded-md border border-black/[0.08] bg-white px-1 text-center text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-primary/40"
1608
1715
  placeholder="%"
1609
1716
  @keydown.stop
@@ -1623,9 +1730,10 @@ const edgeTypes = {
1623
1730
  </DropdownMenu>
1624
1731
  </span>
1625
1732
  </IntegrationsEditorTip>
1626
- <IntegrationsEditorTip label="Zoom in" shortcut="=">
1733
+ <IntegrationsEditorTip :label="t('integration.editor.tool.zoomIn')" shortcut="=">
1627
1734
  <button
1628
1735
  type="button"
1736
+ :aria-label="t('integration.editor.tool.zoomIn')"
1629
1737
  class="flex size-8 items-center justify-center rounded-lg text-foreground transition hover:bg-black/[0.04]"
1630
1738
  @click="flow.zoomIn({ duration: 150 })"
1631
1739
  >
@@ -1644,10 +1752,10 @@ const edgeTypes = {
1644
1752
  >
1645
1753
  <div class="rounded-lg border border-dashed border-black/[0.12] bg-white/80 px-5 py-4 text-center text-sm text-muted-foreground shadow-sm backdrop-blur-sm">
1646
1754
  <div class="font-medium text-foreground mb-1">
1647
- Empty canvas
1755
+ {{ t("integration.editor.emptyState.title") }}
1648
1756
  </div>
1649
1757
  <p>
1650
- Drag a trigger and an action from the panel on the left.
1758
+ {{ t("integration.editor.emptyState.hint") }}
1651
1759
  </p>
1652
1760
  </div>
1653
1761
  </div>
@@ -1669,7 +1777,22 @@ const edgeTypes = {
1669
1777
  renameIntent = false;
1670
1778
  }
1671
1779
  }">
1672
- <DialogContent class="flex h-[86vh] w-[min(1280px,94vw)] max-w-none flex-col gap-0 overflow-hidden rounded-lg p-0 sm:max-w-none">
1780
+ <!-- Sized for the form, not for three columns. 1100px leaves the 1024px
1781
+ reading width a small gutter instead of floating it in the middle,
1782
+ and still gives the form ~770px with the Input panel open. Fixed
1783
+ rather than fitting the content: the tabs hold very different
1784
+ amounts, and a dialog that resizes under the pointer as you switch
1785
+ between them is worse than one with some room to spare.
1786
+
1787
+ A trigger is the exception, and gets a box its own size. It has one
1788
+ tab, no output to test and a handful of controls — a schedule is a
1789
+ frequency, a time and its days — so the node's width would leave the
1790
+ form as a strip down the left of an empty workbench. -->
1791
+ <DialogContent
1792
+ :show-close-button="false"
1793
+ class="flex max-w-none flex-col gap-0 overflow-hidden rounded-lg p-0 sm:max-w-none"
1794
+ :class="selectionIsTrigger ? 'h-[min(520px,86vh)] w-[min(600px,94vw)]' : 'h-[min(680px,86vh)] w-[min(1100px,94vw)]'"
1795
+ >
1673
1796
  <NodeInspector
1674
1797
  v-if="selection"
1675
1798
  :selection="selection"
@@ -1682,6 +1805,7 @@ const edgeTypes = {
1682
1805
  :triggers="blob.triggers"
1683
1806
  :dynamic-outputs="dynamicOutputs"
1684
1807
  :workflow-id="workflow.id"
1808
+ :refused-fields="selectedId ? invalidNodeMessages[selectedId] : void 0"
1685
1809
  @update="applyInspectorUpdate"
1686
1810
  @close="selectedId = null"
1687
1811
  @remove="selectedId && removeBlobNode(selectedId)"