import { Extension } from "@tiptap/core";
import { ReactRenderer } from "@tiptap/react";
import Suggestion, { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
import tippy, { Instance as TippyInstance } from "tippy.js";
import { forwardRef, useImperativeHandle, useState, useRef, useEffect } from "react";
import { cn } from "@/src/lib/general-utils";
import {
Key,
FileInput,
FileJson,
Route,
Code2,
ChevronRight,
Paperclip,
ListOrdered,
} from "lucide-react";
import type { ReactNode } from "react";
import { type CategorizedVariables, type CategorizedSources } from "../context/types";
type ValueType = "object" | "array" | "string" | "number" | "other";
function getValueType(value: unknown): ValueType {
if (value === null || value === undefined) return "other";
if (Array.isArray(value)) return "array";
if (typeof value === "object") return "object";
if (typeof value === "string") return "string";
if (typeof value === "number") return "number";
return "other";
}
function getTypeSymbol(type: ValueType, value?: unknown): string {
if (value === null) return "∅";
if (value === undefined) return "∅";
switch (type) {
case "object":
return "{}";
case "array":
return "[]";
case "string":
return '""';
case "number":
return "123";
default:
return "∅";
}
}
type CategorizedVariablesKey =
| "credentials"
| "toolInputs"
| "fileInputs"
| "currentStepData"
| "previousStepData"
| "paginationVariables";
interface CategoryConfig {
key: CategorizedVariablesKey;
label: string;
icon: ReactNode;
}
const CATEGORY_CONFIGS: CategoryConfig[] = [
{ key: "credentials", label: "Credentials", icon: <Key className="h-4 w-4" /> },
{ key: "toolInputs", label: "Tool Inputs", icon: <FileJson className="h-4 w-4" /> },
{ key: "fileInputs", label: "File Inputs", icon: <Paperclip className="h-4 w-4" /> },
{ key: "currentStepData", label: "Current Step Data", icon: <FileInput className="h-4 w-4" /> },
{ key: "previousStepData", label: "Previous Step Data", icon: <Route className="h-4 w-4" /> },
{ key: "paginationVariables", label: "Pagination", icon: <ListOrdered className="h-4 w-4" /> },
];
interface VariableCommandMenuProps {
categorizedVariables: CategorizedVariables;
categorizedSources?: CategorizedSources;
onSelectVariable: (varName: string, categoryKey: keyof CategorizedVariables) => void;
onSelectCode: () => void;
onRequestClose: () => void;
}
interface VariableCommandMenuRef {
onKeyDown: (event: KeyboardEvent) => boolean;
}
const MENU_WIDTH = 220;
const MAX_LIST_HEIGHT = 220;
function getValueFromSources(
varName: string,
categoryKey: keyof CategorizedVariables,
sources?: CategorizedSources,
): unknown {
if (!sources) return undefined;
switch (categoryKey) {
case "credentials":
return undefined;
case "toolInputs":
return sources.manualPayload?.[varName];
case "fileInputs":
return sources.filePayloads?.[varName];
case "currentStepData":
return varName === "currentItem" ? sources.currentItem : undefined;
case "previousStepData":
return sources.previousStepResults?.[varName];
case "paginationVariables":
return sources.paginationData?.[varName];
default:
return undefined;
}
}
function getNestedValue(obj: unknown, path: string[]): unknown {
let current = obj;
for (const key of path) {
if (current === null || current === undefined || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
type NavigationState =
| { level: "categories" }
| { level: "variables"; category: CategoryConfig }
| { level: "nested"; category: CategoryConfig; varName: string; path: string[] };
const VariableCommandMenu = forwardRef<VariableCommandMenuRef, VariableCommandMenuProps>(
(
{ categorizedVariables, categorizedSources, onSelectVariable, onSelectCode, onRequestClose },
ref,
) => {
const [navState, setNavState] = useState<NavigationState>({ level: "categories" });
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const nonEmptyCategories = CATEGORY_CONFIGS.filter(
(config) => categorizedVariables[config.key]?.length > 0,
);
const getCurrentItems = (): {
items: string[];
canDrill: boolean[];
types: ValueType[];
values: unknown[];
} => {
if (navState.level === "categories") {
return {
items: nonEmptyCategories.map((c) => c.key),
canDrill: nonEmptyCategories.map(() => true),
types: nonEmptyCategories.map(() => "other" as ValueType),
values: nonEmptyCategories.map(() => undefined),
};
}
if (navState.level === "variables") {
const vars = categorizedVariables[navState.category.key] || [];
const isCredentialsCategory = navState.category.key === "credentials";
const values = vars.map((varName) =>
isCredentialsCategory
? undefined
: getValueFromSources(varName, navState.category.key, categorizedSources),
);
const canDrill = values.map((value) => {
if (isCredentialsCategory) return false;
if (getValueType(value) !== "object") return false;
const keys = value && typeof value === "object" ? Object.keys(value) : [];
return keys.length > 0;
});
const types = values.map((value) =>
isCredentialsCategory ? ("string" as ValueType) : getValueType(value),
);
return { items: vars, canDrill, types, values };
}
if (navState.level === "nested") {
const baseValue = getValueFromSources(
navState.varName,
navState.category.key,
categorizedSources,
);
const nestedValue =
navState.path.length > 0 ? getNestedValue(baseValue, navState.path) : baseValue;
if (nestedValue && typeof nestedValue === "object" && !Array.isArray(nestedValue)) {
const keys = Object.keys(nestedValue as Record<string, unknown>);
const values = keys.map((key) => (nestedValue as Record<string, unknown>)[key]);
const canDrill = values.map((val) => {
if (getValueType(val) !== "object") return false;
const nestedKeys = val && typeof val === "object" ? Object.keys(val) : [];
return nestedKeys.length > 0;
});
const types = values.map((val) => getValueType(val));
return { items: keys, canDrill, types, values };
}
}
return { items: [], canDrill: [], types: [], values: [] };
};
const { items, canDrill, types, values } = getCurrentItems();
useEffect(() => {
setSelectedIndex(0);
itemRefs.current = [];
}, [navState]);
useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [selectedIndex]);
const handleBack = () => {
if (navState.level === "categories") {
onRequestClose();
return true;
}
if (navState.level === "variables") {
setNavState({ level: "categories" });
return true;
}
if (navState.level === "nested") {
if (navState.path.length > 0) {
setNavState({ ...navState, path: navState.path.slice(0, -1) });
} else {
setNavState({ level: "variables", category: navState.category });
}
return true;
}
return false;
};
const handleSelect = (index: number) => {
if (navState.level === "categories") {
const category = nonEmptyCategories[index];
if (category) {
setNavState({ level: "variables", category });
}
return;
}
if (navState.level === "variables") {
const varName = items[index];
if (canDrill[index]) {
setNavState({ level: "nested", category: navState.category, varName, path: [] });
} else {
onSelectVariable(varName, navState.category.key);
}
return;
}
if (navState.level === "nested") {
const propKey = items[index];
const pathSegments = [navState.varName, ...navState.path, propKey];
if (canDrill[index]) {
setNavState({ ...navState, path: [...navState.path, propKey] });
} else {
onSelectVariable(pathSegments.join("\x00"), navState.category.key);
}
}
};
useImperativeHandle(ref, () => ({
onKeyDown: (event: KeyboardEvent) => {
const totalItems = items.length + 1;
if (event.key === "ArrowUp") {
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((prev) => (prev + 1) % totalItems);
return true;
}
if (event.key === "Escape" || event.key === "ArrowLeft") {
return handleBack();
}
if (event.key === "Enter" || event.key === "ArrowRight") {
if (selectedIndex === items.length) {
onSelectCode();
return true;
}
if (selectedIndex < items.length) {
handleSelect(selectedIndex);
return true;
}
}
return false;
},
}));
const breadcrumbLabel =
navState.level === "categories"
? null
: navState.level === "nested"
? `${navState.varName}${navState.path.length > 0 ? "." + navState.path.join(".") : ""}`
: navState.category.label;
return (
<div
className="bg-popover border rounded-lg shadow-lg overflow-hidden"
style={{ width: `${MENU_WIDTH}px` }}
>
{breadcrumbLabel && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-b truncate">
{breadcrumbLabel}
</div>
)}
<div className="overflow-y-auto" style={{ maxHeight: `${MAX_LIST_HEIGHT}px` }}>
{navState.level === "categories" ? (
nonEmptyCategories.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">No variables available</div>
) : (
nonEmptyCategories.map((config, index) => (
<button
key={config.key}
ref={(el) => {
itemRefs.current[index] = el;
}}
onClick={() => handleSelect(index)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"w-full flex items-center justify-between gap-2 px-3 py-1.5 text-sm text-left whitespace-nowrap",
selectedIndex === index && "bg-accent",
)}
>
<span className="flex items-center gap-2 text-muted-foreground shrink-0">
{config.icon}
<span className="text-foreground">{config.label}</span>
</span>
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-muted-foreground">
{categorizedVariables[config.key].length}
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</button>
))
)
) : (
items.map((item, index) => {
const typeSymbol = getTypeSymbol(types[index], values[index]);
return (
<button
key={item}
ref={(el) => {
itemRefs.current[index] = el;
}}
onClick={() => handleSelect(index)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"w-full flex items-center justify-between gap-2 px-3 py-1.5 text-sm text-left font-mono",
selectedIndex === index && "bg-accent",
)}
>
<span className="flex items-center gap-1.5 truncate">
{typeSymbol && <span className="text-xs font-mono shrink-0">{typeSymbol}</span>}
<span className="truncate">{item}</span>
</span>
{canDrill[index] && (
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
)}
</button>
);
})
)}
</div>
<div className="h-px bg-border" />
<button
ref={(el) => {
itemRefs.current[items.length] = el;
}}
onClick={() => onSelectCode()}
onMouseEnter={() => setSelectedIndex(items.length)}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm whitespace-nowrap",
selectedIndex === items.length && "bg-accent",
)}
>
<Code2 className="h-4 w-4 text-muted-foreground" />
Code expression
</button>
</div>
);
},
);
VariableCommandMenu.displayName = "VariableCommandMenu";
interface SuggestionCallbacks {
categorizedVariables: CategorizedVariables;
categorizedSources?: CategorizedSources;
onSelectVariable: (
varName: string,
range: { from: number; to: number },
categoryKey: keyof CategorizedVariables,
) => void;
onSelectCode: (range: { from: number; to: number }) => void;
onEscape: (range: { from: number; to: number }) => void;
onOpen?: (destroy: () => void) => void;
onClose?: () => void;
}
export function createVariableSuggestionConfig(callbacks: SuggestionCallbacks) {
return {
char: "@",
allowSpaces: false,
startOfLine: false,
allowedPrefixes: null,
allow: ({ range }) => range.from + 1 >= range.to,
items: () => [],
render: () => {
let component: ReactRenderer<VariableCommandMenuRef> | null = null;
let popup: TippyInstance[] | null = null;
let currentRange: { from: number; to: number } | null = null;
let scrollHandler: (() => void) | null = null;
let currentClientRect: (() => DOMRect) | null = null;
const destroyPopup = () => {
if (scrollHandler) {
window.removeEventListener("scroll", scrollHandler, true);
scrollHandler = null;
}
popup?.[0]?.destroy();
component?.destroy();
currentRange = null;
currentClientRect = null;
};
const makeMenuProps = () => ({
categorizedVariables: callbacks.categorizedVariables,
categorizedSources: callbacks.categorizedSources,
onSelectVariable: (varName: string, categoryKey: keyof CategorizedVariables) => {
if (currentRange) callbacks.onSelectVariable(varName, currentRange, categoryKey);
popup?.[0]?.hide();
},
onSelectCode: () => {
if (currentRange) callbacks.onSelectCode(currentRange);
popup?.[0]?.hide();
},
onRequestClose: destroyPopup,
});
return {
onStart: (props: SuggestionProps<string>) => {
currentRange = props.range;
currentClientRect = props.clientRect as () => DOMRect;
component = new ReactRenderer(VariableCommandMenu, {
props: makeMenuProps(),
editor: props.editor,
});
if (!props.clientRect) return;
const rect = props.clientRect?.();
const wouldOverflowRight = rect && rect.left + MENU_WIDTH > window.innerWidth - 16;
popup = tippy("body", {
getReferenceClientRect: () => currentClientRect?.() ?? rect!,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: wouldOverflowRight ? "bottom-end" : "bottom-start",
offset: [0, 4],
});
scrollHandler = () => popup?.[0]?.popperInstance?.update();
window.addEventListener("scroll", scrollHandler, true);
callbacks.onOpen?.(destroyPopup);
},
onUpdate: (props: SuggestionProps<string>) => {
currentRange = props.range;
component?.updateProps(makeMenuProps());
if (props.clientRect && popup?.[0]) {
popup[0].setProps({ getReferenceClientRect: props.clientRect as () => DOMRect });
}
},
onKeyDown: (props: SuggestionKeyDownProps) => {
return component?.ref?.onKeyDown(props.event) ?? false;
},
onExit: () => {
destroyPopup();
callbacks.onClose?.();
},
};
},
command: () => {},
};
}
export interface VariableSuggestionOptions {
suggestion: ReturnType<typeof createVariableSuggestionConfig>;
}
export const VariableSuggestion = Extension.create<VariableSuggestionOptions>({
name: "variableSuggestion",
addOptions() {
return { suggestion: {} as ReturnType<typeof createVariableSuggestionConfig> };
},
addProseMirrorPlugins() {
return [Suggestion({ editor: this.editor, ...this.options.suggestion })];
},
});