import { assertValidArrowFunction, findTemplateForSystem } from "@superglue/shared";
import { clsx, type ClassValue } from "clsx";
import prettierPluginBabel from "prettier/plugins/babel";
import prettierPluginEstree from "prettier/plugins/estree";
import prettier from "prettier/standalone";
import type { SimpleIcon } from "simple-icons";
import * as simpleIcons from "simple-icons";
import { twMerge } from "tailwind-merge";
import { StepExecutionResult } from "./client-utils";
export const inputErrorStyles = "border-red-500 focus:border-red-500 focus:ring-red-500";
/** Deep equality check for JSON-serializable objects */
export function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
const bObj = b as Record<string, unknown>;
return keysA.every((k) => k in bObj && deepEqual((a as Record<string, unknown>)[k], bObj[k]));
}
export type Theme = "light" | "dark" | "system";
export function getThemeScript(): string {
return `
(function() {
try {
const theme = localStorage.getItem('theme') || 'system';
let resolved;
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
resolved = theme;
}
if (resolved === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
`;
}
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Safe JSON.stringify that handles circular references, BigInt, and other edge cases.
* Falls back to String(obj) on error.
*/
export function safeStringify(obj: any, indent?: number): string {
try {
return JSON.stringify(
obj,
(_, value) => {
if (typeof value === "bigint") return value.toString();
if (typeof value === "function") return "[Function]";
return value;
},
indent,
);
} catch {
return String(obj);
}
}
export function composeUrl(host: string, path: string | undefined) {
if (!host && !path) return "";
if (!host) host = "";
if (!path) path = "";
const cleanHost = host.endsWith("/") ? host.slice(0, -1) : host;
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
if (!cleanPath) {
return cleanHost;
}
return `${cleanHost}/${cleanPath}`;
}
export const isEmptyData = (value: any): boolean => {
if (value === null || value === undefined) return true;
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length === 0) return true;
const first = trimmed[0];
if (first === "{" || first === "[") {
try {
const parsed = JSON.parse(trimmed);
return isEmptyData(parsed);
} catch {
return false;
}
}
return false;
}
if (Array.isArray(value)) return value.length === 0;
if (typeof value === "object") return Object.keys(value).length === 0;
return false;
};
export const computeStepOutput = (
result: StepExecutionResult,
): { output: any; failed: boolean; emptyHint?: boolean; error?: string } => {
const failed = !result?.success;
if (failed) {
return { output: result?.error || "Step execution failed", failed: true, error: result?.error };
}
return {
output: result?.data,
failed: false,
emptyHint: isEmptyData(result?.data),
error: result?.error,
};
};
// Truncation constants for display
export const MAX_DISPLAY_SIZE = 1024 * 1024; // 1MB limit for JSON display
export const MAX_DISPLAY_LINES = 3000; // Max lines to show in any JSON view
export const MAX_STRING_PREVIEW_LENGTH = 3000; // Max chars for individual string values
export const MAX_ARRAY_PREVIEW_ITEMS = 10; // Max array items to show before truncating
export const MAX_TRUNCATION_DEPTH = 10; // Max depth for nested object traversal
export const MAX_OBJECT_PREVIEW_KEYS = 100; // Max object keys to show before truncating
export const truncateValue = (value: any, depth: number = 0): any => {
if (depth > MAX_TRUNCATION_DEPTH) {
if (Array.isArray(value)) return "[...]";
if (typeof value === "object" && value !== null) return "{...}";
return "...";
}
if (typeof value === "function") return "[FUNCTION]";
if (typeof value === "string") {
if (value.length > MAX_STRING_PREVIEW_LENGTH) {
return (
value.substring(0, MAX_STRING_PREVIEW_LENGTH) +
`... [${value.length.toLocaleString()} chars total]`
);
}
return value;
}
if (Array.isArray(value)) {
if (value.length > MAX_ARRAY_PREVIEW_ITEMS) {
return [
...value.slice(0, MAX_ARRAY_PREVIEW_ITEMS).map((v) => truncateValue(v, depth + 1)),
`... ${value.length - MAX_ARRAY_PREVIEW_ITEMS} more items`,
];
}
return value.map((v) => truncateValue(v, depth + 1));
}
if (typeof value === "object" && value !== null) {
const result: any = {};
const keys = Object.keys(value);
const keysToShow = keys.slice(0, MAX_OBJECT_PREVIEW_KEYS);
for (const key of keysToShow) {
result[key] = truncateValue(value[key], depth + 1);
}
if (keys.length > MAX_OBJECT_PREVIEW_KEYS) {
result["..."] = `${(keys.length - MAX_OBJECT_PREVIEW_KEYS).toLocaleString()} more keys`;
}
return result;
}
return value;
};
export const truncateForDisplay = (data: any): { value: string; truncated: boolean } => {
if (data === null || data === undefined) {
return { value: "{}", truncated: false };
}
if (typeof data === "function") {
return { value: JSON.stringify("[FUNCTION]"), truncated: false };
}
if (typeof data === "string") {
if (data.length > MAX_STRING_PREVIEW_LENGTH) {
const truncatedString = data.substring(0, MAX_STRING_PREVIEW_LENGTH);
const lastNewline = truncatedString.lastIndexOf("\n");
const cleanTruncated = truncatedString.substring(
0,
lastNewline > 0 ? lastNewline : MAX_STRING_PREVIEW_LENGTH,
);
const note = `\n\n... [String truncated - showing ${MAX_STRING_PREVIEW_LENGTH.toLocaleString()} of ${data.length.toLocaleString()} characters]`;
const combined = `${cleanTruncated}${note}`;
return { value: JSON.stringify(combined), truncated: true };
}
return { value: JSON.stringify(data), truncated: false };
}
try {
const truncatedData = truncateValue(data);
let jsonString = JSON.stringify(truncatedData, null, 2);
if (jsonString.length > MAX_DISPLAY_SIZE) {
jsonString = jsonString.substring(0, MAX_DISPLAY_SIZE);
const lastNewline = jsonString.lastIndexOf("\n");
if (lastNewline > 0) jsonString = jsonString.substring(0, lastNewline);
return {
value: jsonString + "\n\n... [Data truncated - exceeds size limit]",
truncated: true,
};
}
const lines = jsonString.split("\n");
if (lines.length > MAX_DISPLAY_LINES) {
return {
value:
lines.slice(0, MAX_DISPLAY_LINES).join("\n") + "\n\n... [Truncated - too many lines]",
truncated: true,
};
}
const originalJson = JSON.stringify(data, null, 2);
const wasTruncated = originalJson !== jsonString;
return { value: jsonString, truncated: wasTruncated };
} catch {
const stringValue = String(data);
if (stringValue.length > MAX_STRING_PREVIEW_LENGTH) {
const preview = stringValue.substring(0, MAX_STRING_PREVIEW_LENGTH) + "... [Truncated]";
return { value: JSON.stringify(preview), truncated: true };
}
return { value: JSON.stringify(stringValue), truncated: false };
}
};
export const truncateLines = (text: string, maxLines: number): string => {
if (!text) return text;
const lines = text.split("\n");
if (lines.length <= maxLines) return text;
return (
lines.slice(0, maxLines).join("\n") +
`\n... truncated ${lines.length - maxLines} more lines ...`
);
};
let _simpleIconsBySlug: Map<string, SimpleIcon> | null = null;
function getSimpleIconsBySlug(): Map<string, SimpleIcon> {
if (_simpleIconsBySlug) return _simpleIconsBySlug;
_simpleIconsBySlug = new Map();
for (const key of Object.keys(simpleIcons)) {
if (key.startsWith("si") && key.length > 2) {
// @ts-ignore
const icon = simpleIcons[key] as SimpleIcon;
const normalizedSlug = icon.slug.toLowerCase().replace(/[^a-z0-9]/g, "");
_simpleIconsBySlug.set(normalizedSlug, icon);
}
}
return _simpleIconsBySlug;
}
export function getSimpleIcon(name: string): SimpleIcon | null {
if (!name || name === "default") return null;
const slugMap = getSimpleIconsBySlug();
const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, "");
return slugMap.get(normalized) || null;
}
export interface SimpleIconEntry {
slug: string;
title: string;
hex: string;
}
let _allSimpleIconsCache: SimpleIconEntry[] | null = null;
export function getAllSimpleIcons(): SimpleIconEntry[] {
if (_allSimpleIconsCache) return _allSimpleIconsCache;
const slugMap = getSimpleIconsBySlug();
_allSimpleIconsCache = Array.from(slugMap.values()).map((icon) => ({
slug: icon.slug,
title: icon.title,
hex: icon.hex,
}));
return _allSimpleIconsCache;
}
export function searchSimpleIcons(query: string, limit: number = 20): SimpleIconEntry[] {
if (!query || query.length < 2) return [];
const allIcons = getAllSimpleIcons();
const lowerQuery = query.toLowerCase();
const exactMatches: SimpleIconEntry[] = [];
const startsWithMatches: SimpleIconEntry[] = [];
const containsMatches: SimpleIconEntry[] = [];
for (const icon of allIcons) {
const lowerTitle = icon.title.toLowerCase();
const lowerSlug = icon.slug.toLowerCase();
if (lowerTitle === lowerQuery || lowerSlug === lowerQuery) {
exactMatches.push(icon);
} else if (lowerTitle.startsWith(lowerQuery) || lowerSlug.startsWith(lowerQuery)) {
startsWithMatches.push(icon);
} else if (lowerTitle.includes(lowerQuery) || lowerSlug.includes(lowerQuery)) {
containsMatches.push(icon);
}
}
return [...exactMatches, ...startsWithMatches, ...containsMatches].slice(0, limit);
}
// Resolved icon type - either a SimpleIcon or a Lucide icon name
export type ResolvedIcon =
| { type: "simpleicons"; icon: SimpleIcon }
| { type: "lucide"; name: string }
| null;
/**
* Resolve a system's icon to a renderable format.
* Handles:
* - New format: "simpleicons:salesforce" or "lucide:database"
* - Legacy format: "salesforce" (assumes simpleicons)
* - Fallback to template matching by system id/urlHost
*/
export function resolveSystemIcon(system: {
id?: string;
urlHost?: string;
icon?: string | null;
templateName?: string;
}): ResolvedIcon {
// First, try the system's own icon field
if (system.icon) {
const colonIndex = system.icon.indexOf(":");
if (colonIndex !== -1) {
// New prefixed format
const source = system.icon.substring(0, colonIndex);
const name = system.icon.substring(colonIndex + 1);
if (source === "lucide") {
return { type: "lucide", name };
}
// simpleicons or unknown source - try to resolve
const simpleIcon = getSimpleIcon(name);
if (simpleIcon) {
return { type: "simpleicons", icon: simpleIcon };
}
} else {
// Legacy format - no prefix, assume simpleicons
const simpleIcon = getSimpleIcon(system.icon);
if (simpleIcon) {
return { type: "simpleicons", icon: simpleIcon };
}
}
}
// Fallback: try to get icon from templates via findTemplateForSystem
const match = findTemplateForSystem(system);
if (match?.template.icon) {
const simpleIcon = getSimpleIcon(match.template.icon);
if (simpleIcon) {
return { type: "simpleicons", icon: simpleIcon };
}
}
return null;
}
// Computes the execution-ready payload by merging manual JSON with file payloads
export const computeToolPayload = (
manualPayloadText: string,
filePayloads: Record<string, any>,
): any => {
try {
const manualPayload = JSON.parse(manualPayloadText || "{}");
return { ...manualPayload, ...filePayloads };
} catch {
return { ...filePayloads };
}
};
// Removes file keys from the manual payload text
export const removeFileKeysFromPayload = (payloadText: string, fileKeys: string[]): string => {
if (fileKeys.length === 0) return payloadText;
try {
const parsed = JSON.parse(payloadText || "{}");
fileKeys.forEach((key) => {
delete parsed[key];
});
return JSON.stringify(parsed, null, 2);
} catch {
return payloadText;
}
};
export const buildStepInput = (
initialPayload: any,
steps: any[],
stepResults: Record<string, any>,
upToIndex: number,
) => {
let stepInput = { ...initialPayload };
for (let i = 0; i <= upToIndex && i < steps.length; i++) {
const step = steps[i];
const result = stepResults[step.id];
if (result !== undefined && result !== null) {
stepInput = {
...stepInput,
[`${step.id}`]: result,
};
}
}
return stepInput;
};
export const buildPreviousStepResults = (
steps: any[],
stepResults: Record<string, any>,
upToIndex: number,
): Record<string, any> => {
const results: Record<string, any> = {};
for (let i = 0; i <= upToIndex && i < steps.length; i++) {
const step = steps[i];
const result = stepResults[step.id];
if (result !== undefined && result !== null) {
results[step.id] = result;
}
}
return results;
};
const PRETTIER_PLUGINS = [
(prettierPluginBabel as any).default ?? (prettierPluginBabel as any),
(prettierPluginEstree as any).default ?? (prettierPluginEstree as any),
];
export async function formatJavaScriptCode(code: string): Promise<string> {
if (!code || typeof code !== "string") return code;
try {
const formatted = await prettier.format(code, {
parser: "babel",
plugins: PRETTIER_PLUGINS,
semi: true,
singleQuote: true,
trailingComma: "es5",
tabWidth: 2,
printWidth: 100,
arrowParens: "always",
});
return formatted.trimEnd();
} catch (error) {
console.debug("Code formatting failed:", error);
return code;
}
}
export function getGroupedTimezones(): Record<string, Array<{ value: string; label: string }>> {
const timezones = Intl.supportedValuesOf("timeZone").map((tz) => ({
value: tz,
label: tz.replace(/_/g, " "),
}));
return timezones.reduce(
(acc, timezone) => {
const group = timezone.value.split("/")[0]; // Use value instead of label for grouping
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(timezone);
return acc;
},
{} as Record<string, Array<{ value: string; label: string }>>,
);
}
/**
* Wraps a loop selector function to limit the number of iterations
* @param loopSelectorCode The original loop selector code
* @param limit Maximum number of items to return (defaults to 1)
* @returns Wrapped code that limits the array to specified number of items
*
* @example
* const original = "(sourceData) => { return [1,2,3] }";
* const wrapped = wrapLoopSelectorWithLimit(original, 1);
* // Result: "(sourceData) => { const originalFunction = (sourceData) => { return [1,2,3] }; const out = originalFunction(sourceData); return Array.isArray(out) ? out.slice(0, 1) : out; }"
*/
export function wrapLoopSelectorWithLimit(
loopSelectorCode: string | undefined | null,
limit: number = 1,
): string {
if (!loopSelectorCode || !loopSelectorCode.trim()) {
return loopSelectorCode || "";
}
const trimmedCode = assertValidArrowFunction(loopSelectorCode);
return `(sourceData) => {
const originalFunction = ${trimmedCode};
const out = originalFunction(sourceData);
return Array.isArray(out) ? out.slice(0, ${limit}) : out;
}`;
}
export function isAbortError(errorMessage: string | undefined): boolean {
if (!errorMessage) return false;
return errorMessage.startsWith("AbortError:");
}
export function formatDurationShort(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) {
const mins = Math.floor(ms / 60000);
const secs = Math.round((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
}
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
return `${hours}h ${mins}m`;
}
export const handleCopyCode = async (code: string): Promise<boolean> => {
try {
const decodedCode = code
.replace(/"/g, '"')
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/&/g, "&");
await navigator.clipboard.writeText(decodedCode);
return true;
} catch {
return false;
}
};