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 './tiptap/TemplateContext';
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 '∅';
}
}
interface CategoryConfig {
key: keyof CategorizedVariables;
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 })];
},
});