import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core'; import { DndContext, DragOverlay, PointerSensor, pointerWithin, useDraggable, useDroppable, useSensor, useSensors, } from '@dnd-kit/core'; import { basename } from '@tauri-apps/api/path'; import classNames from 'classnames'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { WrappedEnvironmentVariable } from '../../hooks/useEnvironmentVariables'; import { useRandomKey } from '../../hooks/useRandomKey'; import { useToggle } from '../../hooks/useToggle'; import { languageFromContentType } from '../../lib/contentType'; import { showDialog } from '../../lib/dialog'; import { computeSideForDragMove } from '../../lib/dnd'; import { showPrompt } from '../../lib/prompt'; import { DropMarker } from '../DropMarker'; import { SelectFile } from '../SelectFile'; import { Button } from './Button'; import { Checkbox } from './Checkbox'; import type { DropdownItem } from './Dropdown'; import { Dropdown } from './Dropdown'; import type { EditorProps } from './Editor/Editor'; import type { GenericCompletionConfig } from './Editor/genericCompletion'; import { Editor } from './Editor/LazyEditor'; import { Icon } from './Icon'; import { IconButton } from './IconButton'; import type { InputHandle, InputProps } from './Input'; import { Input } from './Input'; import { ensurePairId } from './PairEditor.util'; import type { RadioDropdownItem } from './RadioDropdown'; import { RadioDropdown } from './RadioDropdown'; export interface PairEditorHandle { focusName(id: string): void; focusValue(id: string): void; } export type PairEditorProps = { allowFileValues?: boolean; allowMultilineValues?: boolean; className?: string; forcedEnvironmentId?: string; forceUpdateKey?: string; nameAutocomplete?: GenericCompletionConfig; nameAutocompleteFunctions?: boolean; nameAutocompleteVariables?: boolean; namePlaceholder?: string; nameValidate?: InputProps['validate']; noScroll?: boolean; onChange: (pairs: PairWithId[]) => void; pairs: Pair[]; stateKey: InputProps['stateKey']; setRef?: (n: PairEditorHandle) => void; valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; valueAutocompleteFunctions?: boolean; valueAutocompleteVariables?: boolean | 'environment'; valuePlaceholder?: string; valueType?: InputProps['type'] | ((pair: Pair) => InputProps['type']); valueValidate?: InputProps['validate']; }; export type Pair = { id?: string; enabled?: boolean; name: string; value: string; contentType?: string; filename?: string; isFile?: boolean; readOnlyName?: boolean; }; export type PairWithId = Pair & { id: string; }; /** Max number of pairs to show before prompting the user to reveal the rest */ const MAX_INITIAL_PAIRS = 30; export function PairEditor({ allowFileValues, allowMultilineValues, className, forcedEnvironmentId, forceUpdateKey, nameAutocomplete, nameAutocompleteFunctions, nameAutocompleteVariables, namePlaceholder, nameValidate, noScroll, onChange, pairs: originalPairs, stateKey, valueAutocomplete, valueAutocompleteFunctions, valueAutocompleteVariables, valuePlaceholder, valueType, valueValidate, setRef, }: PairEditorProps) { const [hoveredIndex, setHoveredIndex] = useState(null); const [isDragging, setIsDragging] = useState(null); const [pairs, setPairs] = useState([]); const [showAll, toggleShowAll] = useToggle(false); // NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If // we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale. const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey(); const rowsRef = useRef>({}); const handle = useMemo( () => ({ focusName(id: string) { rowsRef.current[id]?.focusName(); }, focusValue(id: string) { rowsRef.current[id]?.focusValue(); }, }), [], ); const initPairEditorRow = useCallback( (id: string, n: RowHandle | null) => { const isLast = id === pairs[pairs.length - 1]?.id; if (isLast) return; // Never add the last pair rowsRef.current[id] = n; const validHandles = Object.values(rowsRef.current).filter((v) => v != null); // Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke) const ready = validHandles.length >= pairs.length - 1; if (ready) { setRef?.(handle); } }, [handle, pairs, setRef], ); // biome-ignore lint/correctness/useExhaustiveDependencies: Only care about forceUpdateKey useEffect(() => { // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs) const newPairs: PairWithId[] = []; for (let i = 0; i < originalPairs.length; i++) { const p = originalPairs[i]; if (!p) continue; // Make TS happy if (isPairEmpty(p)) continue; newPairs.push(ensurePairId(p)); } // Add empty last pair if there is none const lastPair = newPairs[newPairs.length - 1]; if (lastPair == null || !isPairEmpty(lastPair)) { newPairs.push(emptyPair()); } setPairs(newPairs); regenerateLocalForceUpdateKey(); }, [forceUpdateKey]); const setPairsAndSave = useCallback( (fn: (pairs: PairWithId[]) => PairWithId[]) => { setPairs((oldPairs) => { const pairs = fn(oldPairs); onChange(pairs); return pairs; }); }, [onChange], ); const handleChange = useCallback( (pair: PairWithId) => setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), [setPairsAndSave], ); const handleDelete = useCallback( (pair: Pair, focusPrevious: boolean) => { if (focusPrevious) { const index = pairs.findIndex((p) => p.id === pair.id); const id = pairs[index - 1]?.id ?? null; rowsRef.current[id ?? 'n/a']?.focusName(); } return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)); }, [setPairsAndSave, pairs], ); const handleFocusName = useCallback( (pair: Pair) => { const isLast = pair.id === pairs[pairs.length - 1]?.id; if (isLast) setPairs([...pairs, emptyPair()]); }, [pairs], ); const handleFocusValue = useCallback( (pair: Pair) => { const isLast = pair.id === pairs[pairs.length - 1]?.id; if (isLast) setPairs([...pairs, emptyPair()]); }, [pairs], ); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); // dnd-kit: show the “between rows” marker while hovering const onDragMove = useCallback( (e: DragMoveEvent) => { const overId = e.over?.id as string | undefined; if (!overId) return setHoveredIndex(null); const overPair = pairs.find((p) => p.id === overId); if (overPair == null) return setHoveredIndex(null); const side = computeSideForDragMove(overPair.id, e); const overIndex = pairs.findIndex((p) => p.id === overId); const hoveredIndex = overIndex + (side === 'before' ? 0 : 1); setHoveredIndex(hoveredIndex); }, [pairs], ); const onDragStart = useCallback( (e: DragStartEvent) => { const pair = pairs.find((p) => p.id === e.active.id); setIsDragging(pair ?? null); }, [pairs], ); const onDragCancel = useCallback(() => setIsDragging(null), []); const onDragEnd = useCallback( (e: DragEndEvent) => { setIsDragging(null); setHoveredIndex(null); const activeId = e.active.id as string | undefined; const overId = e.over?.id as string | undefined; if (!activeId || !overId) return; const from = pairs.findIndex((p) => p.id === activeId); const baseTo = pairs.findIndex((p) => p.id === overId); const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo); if (from !== -1 && to !== -1 && from !== to) { setPairsAndSave((ps) => { const next = [...ps]; const [moved] = next.splice(from, 1); if (moved === undefined) return ps; // Make TS happy next.splice(to > from ? to - 1 : to, 0, moved); return next; }); } }, [pairs, hoveredIndex, setPairsAndSave], ); return (
{pairs.map((p, i) => { if (!showAll && i > MAX_INITIAL_PAIRS) return null; const isLast = i === pairs.length - 1; return ( {hoveredIndex === i && } ); })} {!showAll && pairs.length > MAX_INITIAL_PAIRS && ( )} {isDragging && ( )}
{ e.preventDefault(); e.stopPropagation(); }} />
); } type PairEditorRowProps = { className?: string; pair: PairWithId; forceFocusNamePairId?: string | null; forceFocusValuePairId?: string | null; onChange?: (pair: PairWithId) => void; onDelete?: (pair: PairWithId, focusPrevious: boolean) => void; onFocusName?: (pair: PairWithId) => void; onFocusValue?: (pair: PairWithId) => void; onSubmit?: (pair: PairWithId) => void; isLast?: boolean; disabled?: boolean; disableDrag?: boolean; index: number; isDraggingGlobal?: boolean; setRef?: (id: string, n: RowHandle | null) => void; } & Pick< PairEditorProps, | 'allowFileValues' | 'allowMultilineValues' | 'forcedEnvironmentId' | 'forceUpdateKey' | 'nameAutocomplete' | 'nameAutocompleteVariables' | 'namePlaceholder' | 'nameValidate' | 'nameAutocompleteFunctions' | 'stateKey' | 'valueAutocomplete' | 'valueAutocompleteFunctions' | 'valueAutocompleteVariables' | 'valuePlaceholder' | 'valueType' | 'valueValidate' >; interface RowHandle { focusName(): void; focusValue(): void; } export function PairEditorRow({ allowFileValues, allowMultilineValues, className, disableDrag, disabled, forceUpdateKey, forcedEnvironmentId, index, isLast, nameAutocomplete, nameAutocompleteFunctions, nameAutocompleteVariables, namePlaceholder, nameValidate, isDraggingGlobal, onChange, onDelete, onFocusName, onFocusValue, pair, stateKey, valueAutocomplete, valueAutocompleteFunctions, valueAutocompleteVariables, valuePlaceholder, valueType, valueValidate, setRef, }: PairEditorRowProps) { const nameInputRef = useRef(null); const valueInputRef = useRef(null); const handle = useRef({ focusName() { nameInputRef.current?.focus(); }, focusValue() { valueInputRef.current?.focus(); }, }); const initNameInputRef = useCallback( (n: InputHandle | null) => { nameInputRef.current = n; if (nameInputRef.current && valueInputRef.current) { setRef?.(pair.id, handle.current); } }, [pair.id, setRef], ); const initValueInputRef = useCallback( (n: InputHandle | null) => { valueInputRef.current = n; if (nameInputRef.current && valueInputRef.current) { setRef?.(pair.id, handle.current); } }, [pair.id, setRef], ); const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]); const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]); const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]); const handleChangeEnabled = useMemo( () => (enabled: boolean) => onChange?.({ ...pair, enabled }), [onChange, pair], ); const handleChangeName = useMemo( () => (name: string) => onChange?.({ ...pair, name }), [onChange, pair], ); const handleChangeValueText = useMemo( () => (value: string) => onChange?.({ ...pair, value, isFile: false }), [onChange, pair], ); const handleChangeValueFile = useMemo( () => ({ filePath }: { filePath: string | null }) => onChange?.({ ...pair, value: filePath ?? '', isFile: true }), [onChange, pair], ); const handleChangeValueContentType = useMemo( () => (contentType: string) => onChange?.({ ...pair, contentType }), [onChange, pair], ); const handleChangeValueFilename = useMemo( () => (filename: string) => onChange?.({ ...pair, filename }), [onChange, pair], ); const handleEditMultiLineValue = useCallback( () => showDialog({ id: 'pair-edit-multiline', size: 'dynamic', title: <>Edit {pair.name}, render: ({ hide }) => ( ), }), [handleChangeValueText, pair.contentType, pair.name, pair.value], ); const defaultItems = useMemo( (): DropdownItem[] => [ { label: 'Edit Multi-line', onSelect: handleEditMultiLineValue, hidden: !allowMultilineValues, }, { label: 'Delete', onSelect: handleDelete, color: 'danger', }, ], [allowMultilineValues, handleDelete, handleEditMultiLineValue], ); const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id }); const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id }); // Filter out the current pair name const valueAutocompleteVariablesFiltered = useMemo(() => { if (valueAutocompleteVariables === 'environment') { return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name; } return valueAutocompleteVariables; }, [pair.name, valueAutocompleteVariables]); const handleSetRef = useCallback( (n: HTMLDivElement | null) => { setDraggableRef(n); setDroppableRef(n); }, [setDraggableRef, setDroppableRef], ); return (
{!isLast && !disableDrag ? (
) : ( )}
{pair.isFile ? ( ) : pair.value.includes('\n') ? ( ) : ( )}
{allowFileValues ? ( ) : ( )}
); } const fileItems: RadioDropdownItem[] = [ { label: 'Text', value: 'text' }, { label: 'File', value: 'file' }, ]; function FileActionsDropdown({ pair, onChangeFile, onChangeText, onChangeContentType, onChangeFilename, onDelete, editMultiLine, }: { pair: Pair; onChangeFile: ({ filePath }: { filePath: string | null }) => void; onChangeText: (text: string) => void; onChangeContentType: (contentType: string) => void; onChangeFilename: (filename: string) => void; onDelete: () => void; editMultiLine: () => void; }) { const onChange = useCallback( (v: string) => { if (v === 'file') onChangeFile({ filePath: '' }); else onChangeText(''); }, [onChangeFile, onChangeText], ); const itemsAfter = useMemo( () => [ { label: 'Edit Multi-Line', leftSlot: , hidden: pair.isFile, onSelect: editMultiLine, }, { label: 'Set Content-Type', leftSlot: , onSelect: async () => { const contentType = await showPrompt({ id: 'content-type', title: 'Override Content-Type', label: 'Content-Type', required: false, placeholder: 'text/plain', defaultValue: pair.contentType ?? '', confirmText: 'Set', description: 'Leave blank to auto-detect', }); if (contentType == null) return; onChangeContentType(contentType); }, }, { label: 'Set File Name', leftSlot: , onSelect: async () => { console.log('PAIR', pair); const defaultFilename = await basename(pair.value ?? ''); const filename = await showPrompt({ id: 'filename', title: 'Override Filename', label: 'Filename', required: false, placeholder: defaultFilename ?? 'myfile.png', defaultValue: pair.filename, confirmText: 'Set', description: 'Leave blank to use the name of the selected file', }); if (filename == null) return; onChangeFilename(filename); }, }, { label: 'Unset File', leftSlot: , hidden: pair.isFile, onSelect: async () => { onChangeFile({ filePath: null }); }, }, { label: 'Delete', onSelect: onDelete, variant: 'danger', leftSlot: , color: 'danger', }, ], [ editMultiLine, onChangeContentType, onChangeFile, onDelete, pair.contentType, pair.isFile, onChangeFilename, pair.filename, pair, ], ); return ( ); } function emptyPair(): PairWithId { return ensurePairId({ enabled: true, name: '', value: '' }); } function isPairEmpty(pair: Pair): boolean { return !pair.name && !pair.value; } function MultilineEditDialog({ defaultValue, contentType, onChange, hide, }: { defaultValue: string; contentType: string | null; onChange: (value: string) => void; hide: () => void; }) { const [value, setValue] = useState(defaultValue); const language = languageFromContentType(contentType, value); return (
); }