import { open } from '@tauri-apps/api/dialog'; import classNames from 'classnames'; import type { EditorView } from 'codemirror'; import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; import { v4 as uuid } from 'uuid'; import { DropMarker } from '../DropMarker'; import { Button } from './Button'; import { Checkbox } from './Checkbox'; import { Dropdown } from './Dropdown'; import type { GenericCompletionConfig } from './Editor/genericCompletion'; import { Icon } from './Icon'; import { IconButton } from './IconButton'; import type { InputProps } from './Input'; import { Input } from './Input'; export type PairEditorProps = { pairs: Pair[]; onChange: (pairs: Pair[]) => void; forceUpdateKey?: string; className?: string; namePlaceholder?: string; valuePlaceholder?: string; nameAutocomplete?: GenericCompletionConfig; valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; nameAutocompleteVariables?: boolean; valueAutocompleteVariables?: boolean; allowFileValues?: boolean; nameValidate?: InputProps['validate']; valueValidate?: InputProps['validate']; }; export type Pair = { id?: string; enabled?: boolean; name: string; value: string; isFile?: boolean; }; type PairContainer = { pair: Pair; id: string; }; export const PairEditor = memo(function PairEditor({ className, forceUpdateKey, nameAutocomplete, nameAutocompleteVariables, namePlaceholder, nameValidate, onChange, pairs: originalPairs, valueAutocomplete, valueAutocompleteVariables, valuePlaceholder, valueValidate, allowFileValues, }: PairEditorProps) { const [forceFocusPairId, setForceFocusPairId] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); const [pairs, setPairs] = useState(() => { // Remove empty headers on initial render const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === '')); const pairs = nonEmpty.map((pair) => newPairContainer(pair)); return [...pairs, newPairContainer()]; }); useEffect(() => { // Remove empty headers on initial render // TODO: Make this not refresh the entire editor when forceUpdateKey changes, using some // sort of diff method or deterministic IDs based on array index and update key const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === '')); const pairs = nonEmpty.map((pair) => newPairContainer(pair)); setPairs([...pairs, newPairContainer()]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [forceUpdateKey]); const setPairsAndSave = useCallback( (fn: (pairs: PairContainer[]) => PairContainer[]) => { setPairs((oldPairs) => { const pairs = fn(oldPairs).map((p) => p.pair); onChange(pairs); return fn(oldPairs); }); }, [onChange], ); const handleMove = useCallback( (id, side) => { const dragIndex = pairs.findIndex((r) => r.id === id); setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1); }, [pairs], ); const handleEnd = useCallback( (id: string) => { if (hoveredIndex === null) return; setHoveredIndex(null); setPairsAndSave((pairs) => { const index = pairs.findIndex((p) => p.id === id); const pair = pairs[index]; if (pair === undefined) return pairs; const newPairs = pairs.filter((p) => p.id !== id); if (hoveredIndex > index) newPairs.splice(hoveredIndex - 1, 0, pair); else newPairs.splice(hoveredIndex, 0, pair); return newPairs; }); }, [hoveredIndex, setPairsAndSave], ); const handleChange = useCallback( (pair: PairContainer) => setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), [setPairsAndSave], ); const handleDelete = useCallback( (pair: PairContainer, focusPrevious: boolean) => { if (focusPrevious) { const index = pairs.findIndex((p) => p.id === pair.id); const id = pairs[index - 1]?.id ?? null; setForceFocusPairId(id); } return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)); }, [setPairsAndSave, setForceFocusPairId, pairs], ); const handleFocus = useCallback( (pair: PairContainer) => setPairs((pairs) => { setForceFocusPairId(null); // Remove focus override when something focused const isLast = pair.id === pairs[pairs.length - 1]?.id; return isLast ? [...pairs, newPairContainer()] : pairs; }), [], ); // Ensure there's always at least one pair useEffect(() => { if (pairs.length === 0) { setPairs((pairs) => [...pairs, newPairContainer()]); } }, [pairs]); return (
{pairs.map((p, i) => { const isLast = i === pairs.length - 1; return ( {hoveredIndex === i && } ); })}
); }); enum ItemTypes { ROW = 'pair-row', } type FormRowProps = { className?: string; pairContainer: PairContainer; forceFocusPairId?: string | null; onMove: (id: string, side: 'above' | 'below') => void; onEnd: (id: string) => void; onChange: (pair: PairContainer) => void; onDelete?: (pair: PairContainer, focusPrevious: boolean) => void; onFocus?: (pair: PairContainer) => void; onSubmit?: (pair: PairContainer) => void; isLast?: boolean; } & Pick< PairEditorProps, | 'nameAutocomplete' | 'valueAutocomplete' | 'nameAutocompleteVariables' | 'valueAutocompleteVariables' | 'namePlaceholder' | 'valuePlaceholder' | 'nameValidate' | 'valueValidate' | 'forceUpdateKey' | 'allowFileValues' >; const FormRow = memo(function FormRow({ allowFileValues, className, forceFocusPairId, forceUpdateKey, isLast, nameAutocomplete, nameAutocompleteVariables, namePlaceholder, nameValidate, onChange, onDelete, onEnd, onFocus, onMove, pairContainer, valueAutocomplete, valueAutocompleteVariables, valuePlaceholder, valueValidate, }: FormRowProps) { const { id } = pairContainer; const ref = useRef(null); const nameInputRef = useRef(null); useEffect(() => { if (forceFocusPairId === pairContainer.id) { nameInputRef.current?.focus(); } }, [forceFocusPairId, pairContainer.id]); const handleChangeEnabled = useMemo( () => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }), [id, onChange, pairContainer.pair], ); const handleChangeName = useMemo( () => (name: string) => onChange({ id, pair: { ...pairContainer.pair, name } }), [onChange, id, pairContainer.pair], ); const handleChangeValueText = useMemo( () => (value: string) => onChange({ id, pair: { ...pairContainer.pair, value, isFile: false } }), [onChange, id, pairContainer.pair], ); const handleChangeValueFile = useMemo( () => (value: string) => onChange({ id, pair: { ...pairContainer.pair, value, isFile: true } }), [onChange, id, pairContainer.pair], ); const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]); const handleDelete = useCallback( () => onDelete?.(pairContainer, false), [onDelete, pairContainer], ); const [, connectDrop] = useDrop( { accept: ItemTypes.ROW, hover: (_, monitor) => { if (!ref.current) return; const hoverBoundingRect = ref.current?.getBoundingClientRect(); const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; const clientOffset = monitor.getClientOffset(); const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; onMove(pairContainer.id, hoverClientY < hoverMiddleY ? 'above' : 'below'); }, }, [onMove], ); const [, connectDrag] = useDrag( { type: ItemTypes.ROW, item: () => pairContainer, collect: (m) => ({ isDragging: m.isDragging() }), end: () => onEnd(pairContainer.id), }, [pairContainer, onEnd], ); connectDrag(ref); connectDrop(ref); return (
{!isLast ? (
) : ( )}
{pairContainer.pair.isFile ? ( ) : ( )} {allowFileValues && ( handleChangeValueText('') }, { key: 'file', label: 'File', onSelect: () => handleChangeValueFile('') }, ]} > )}
); }); const newPairContainer = (initialPair?: Pair): PairContainer => { const id = initialPair?.id ?? uuid(); const pair = initialPair ?? { name: '', value: '', enabled: true, isFile: false }; return { id, pair }; }; const getFileName = (path: string): string => { const parts = path.split(/[\\/]/); return parts[parts.length - 1] ?? ''; };