diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/environment/environment.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/environment/environment.tsx index 409e2e368e..db203d946b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/environment/environment.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/environment/environment.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Info, Plus, Search, Share2 } from 'lucide-react' +import { Plus, Search, Share2, Undo2 } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, Input as EmcnInput, Tooltip } from '@/components/emcn' import { @@ -27,7 +27,6 @@ const logger = createLogger('EnvironmentVariables') const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto] items-center' const ENV_VAR_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ -const PERSONAL_VAR_INDEX_OFFSET = 1000 const PRIMARY_BUTTON_STYLES = '!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90' @@ -39,6 +38,12 @@ const generateRowId = (() => { } })() +const createEmptyEnvVar = (): UIEnvironmentVariable => ({ + key: '', + value: '', + id: generateRowId(), +}) + interface UIEnvironmentVariable { key: string value: string @@ -49,113 +54,79 @@ interface EnvironmentVariablesProps { registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void } -interface VariableRowProps { +interface WorkspaceVariableRowProps { envKey: string value: string - isNew: boolean - focusedValueIndex: number | null - rowIndex: number - onKeyChange: (value: string) => void - onValueChange: (value: string) => void - onValueFocus: (index: number, e: React.FocusEvent) => void - onValueBlur: () => void - onDelete: () => void - onPromote?: () => void - canPromote?: boolean - isConflict?: boolean + renamingKey: string | null + pendingKeyValue: string + isNewlyPromoted: boolean + onRenameStart: (key: string) => void + onPendingKeyChange: (value: string) => void + onRenameEnd: (key: string, value: string) => void + onDelete: (key: string) => void + onDemote: (key: string, value: string) => void } -function VariableRow({ +function WorkspaceVariableRow({ envKey, value, - isNew, - focusedValueIndex, - rowIndex, - onKeyChange, - onValueChange, - onValueFocus, - onValueBlur, + renamingKey, + pendingKeyValue, + isNewlyPromoted, + onRenameStart, + onPendingKeyChange, + onRenameEnd, onDelete, - onPromote, - canPromote, - isConflict, -}: VariableRowProps) { - const conflictClassName = 'border-[var(--text-error)] bg-[#F6D2D2] dark:bg-[#442929]' - const maskedValueStyle = - focusedValueIndex !== rowIndex && !isConflict - ? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties) - : undefined - + onDemote, +}: WorkspaceVariableRowProps) { return ( - <> -
- isNew && onKeyChange(e.target.value)} - placeholder='API_KEY' - name={`env_key_${rowIndex}`} - autoComplete='off' - autoCapitalize='off' - spellCheck='false' - disabled={!isNew} - readOnly={!isNew} - onFocus={(e) => isNew && e.target.removeAttribute('readOnly')} - className={`h-9 ${!isNew ? 'cursor-not-allowed' : ''} ${isConflict ? conflictClassName : ''}`} - /> -
- isNew && onValueChange(e.target.value)} - type='text' - onFocus={(e) => { - if (isNew && !isConflict) { - e.target.removeAttribute('readOnly') - onValueFocus(rowIndex, e) - } - }} - onBlur={onValueBlur} - placeholder={isConflict ? 'Workspace override active' : 'Enter value'} - disabled={!isNew || isConflict} - name={`env_value_${rowIndex}`} - autoComplete='off' - autoCapitalize='off' - spellCheck='false' - readOnly={!isNew || isConflict} - style={isNew ? maskedValueStyle : undefined} - className={`h-9 ${!isNew || isConflict ? 'cursor-not-allowed' : ''} ${isConflict ? conflictClassName : ''}`} - /> -
- {onPromote && ( - - - - - Promote to workspace - - )} +
+ { + if (renamingKey !== envKey) onRenameStart(envKey) + onPendingKeyChange(e.target.value) + }} + onBlur={() => onRenameEnd(envKey, value)} + name={`workspace_env_key_${envKey}_${Math.random()}`} + autoComplete='off' + autoCapitalize='off' + spellCheck='false' + readOnly + onFocus={(e) => e.target.removeAttribute('readOnly')} + className='h-9' + /> +
+ +
+ {isNewlyPromoted && ( - - Delete + Change to personal scope -
+ )} + + + + + Delete environment variable +
- {isConflict && ( -
- Workspace variable with the same name overrides this. Rename your personal key to use it. -
- )} - +
) } @@ -182,99 +153,76 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment const removeWorkspaceMutation = useRemoveWorkspaceEnvironment() const isLoading = isPersonalLoading || isWorkspaceLoading + const variables = useMemo(() => personalEnvData || {}, [personalEnvData]) - const [personalVars, setPersonalVars] = useState([]) - const [workspaceVars, setWorkspaceVars] = useState([]) + const [envVars, setEnvVars] = useState([]) const [searchTerm, setSearchTerm] = useState('') const [focusedValueIndex, setFocusedValueIndex] = useState(null) const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false) - const [initialPersonalVars, setInitialPersonalVars] = useState([]) - const [initialWorkspaceVars, setInitialWorkspaceVars] = useState([]) + const [workspaceVars, setWorkspaceVars] = useState>({}) + const [conflicts, setConflicts] = useState([]) + const [renamingKey, setRenamingKey] = useState(null) + const [pendingKeyValue, setPendingKeyValue] = useState('') + const [changeToken, setChangeToken] = useState(0) + const initialWorkspaceVarsRef = useRef>({}) const scrollContainerRef = useRef(null) const pendingProceedCallback = useRef<(() => void) | null>(null) + const initialVarsRef = useRef([]) const hasChangesRef = useRef(false) const hasSavedRef = useRef(false) - const filteredPersonalVars = useMemo(() => { - if (!searchTerm.trim()) return personalVars + const filteredEnvVars = useMemo(() => { + const mapped = envVars.map((envVar, index) => ({ envVar, originalIndex: index })) + if (!searchTerm.trim()) return mapped const term = searchTerm.toLowerCase() - return personalVars.filter((v) => v.key.toLowerCase().includes(term)) - }, [personalVars, searchTerm]) + return mapped.filter(({ envVar }) => envVar.key.toLowerCase().includes(term)) + }, [envVars, searchTerm]) - const filteredWorkspaceVars = useMemo(() => { - if (!searchTerm.trim()) return workspaceVars + const filteredWorkspaceEntries = useMemo(() => { + const entries = Object.entries(workspaceVars) + if (!searchTerm.trim()) return entries const term = searchTerm.toLowerCase() - return workspaceVars.filter((v) => v.key.toLowerCase().includes(term)) + return entries.filter(([key]) => key.toLowerCase().includes(term)) }, [workspaceVars, searchTerm]) - const workspaceKeysSet = useMemo( - () => new Set(workspaceVars.map((v) => v.key).filter(Boolean)), - [workspaceVars] - ) - const hasChanges = useMemo(() => { - const compareVars = (current: UIEnvironmentVariable[], initial: UIEnvironmentVariable[]) => { - const currentFiltered = current.filter((v) => v.key || v.value) - const initialFiltered = initial.filter((v) => v.key || v.value) - if (currentFiltered.length !== initialFiltered.length) return true - const initialMap = new Map(initialFiltered.map((v) => [v.key, v.value])) - for (const v of currentFiltered) { - if (initialMap.get(v.key) !== v.value) return true - } - return false + const initialVars = initialVarsRef.current.filter((v) => v.key || v.value) + const currentVars = envVars.filter((v) => v.key || v.value) + const initialMap = new Map(initialVars.map((v) => [v.key, v.value])) + const currentMap = new Map(currentVars.map((v) => [v.key, v.value])) + + if (initialMap.size !== currentMap.size) return true + + for (const [key, value] of currentMap) { + if (initialMap.get(key) !== value) return true } - return ( - compareVars(personalVars, initialPersonalVars) || - compareVars(workspaceVars, initialWorkspaceVars) - ) - }, [personalVars, workspaceVars, initialPersonalVars, initialWorkspaceVars]) - - const hasConflicts = useMemo( - () => personalVars.some((v) => v.key && workspaceKeysSet.has(v.key)), - [personalVars, workspaceKeysSet] - ) - const hasDuplicateWorkspaceKeys = useMemo(() => { - const keys = workspaceVars.map((v) => v.key).filter(Boolean) - return keys.length !== new Set(keys).size - }, [workspaceVars]) + for (const key of initialMap.keys()) { + if (!currentMap.has(key)) return true + } - const hasDuplicatePersonalKeys = useMemo(() => { - const keys = personalVars.map((v) => v.key).filter(Boolean) - return keys.length !== new Set(keys).size - }, [personalVars]) + const before = initialWorkspaceVarsRef.current + const after = workspaceVars + const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]) - useEffect(() => { - hasChangesRef.current = hasChanges - }, [hasChanges]) + if (Object.keys(before).length !== Object.keys(after).length) return true - useEffect(() => { - if (hasSavedRef.current) return - const vars = Object.values(personalEnvData || {}).map((v) => ({ - ...v, - id: generateRowId(), - })) - setInitialPersonalVars(structuredClone(vars)) - setPersonalVars(structuredClone(vars)) - }, [personalEnvData]) + for (const key of allKeys) { + if (before[key] !== after[key]) return true + } + + return false + }, [envVars, workspaceVars, changeToken]) + + const hasConflicts = useMemo(() => { + return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key)) + }, [envVars, workspaceVars]) useEffect(() => { - if (hasSavedRef.current) { - hasSavedRef.current = false - return - } - if (workspaceEnvData?.workspace) { - const vars = Object.entries(workspaceEnvData.workspace).map(([key, value]) => ({ - key, - value, - id: generateRowId(), - })) - setInitialWorkspaceVars(structuredClone(vars)) - setWorkspaceVars(structuredClone(vars)) - } - }, [workspaceEnvData]) + hasChangesRef.current = hasChanges + }, [hasChanges]) const handleBeforeLeave = useCallback((onProceed: () => void) => { if (hasChangesRef.current) { @@ -285,6 +233,34 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment } }, []) + useEffect(() => { + if (hasSavedRef.current) return + + const existingVars = Object.values(variables) + const initialVars = existingVars.length + ? existingVars.map((envVar) => ({ + ...envVar, + id: generateRowId(), + })) + : [createEmptyEnvVar()] + initialVarsRef.current = JSON.parse(JSON.stringify(initialVars)) + setEnvVars(JSON.parse(JSON.stringify(initialVars))) + pendingProceedCallback.current = null + }, [variables]) + + useEffect(() => { + if (workspaceEnvData) { + if (hasSavedRef.current) { + setConflicts(workspaceEnvData?.conflicts || []) + hasSavedRef.current = false + } else { + setWorkspaceVars(workspaceEnvData?.workspace || {}) + initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {} + setConflicts(workspaceEnvData?.conflicts || []) + } + } + }, [workspaceEnvData]) + useEffect(() => { if (registerBeforeLeaveHandler) { registerBeforeLeaveHandler(handleBeforeLeave) @@ -301,109 +277,191 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment } }, [shouldScrollToBottom]) - const addWorkspaceVar = useCallback(() => { - setWorkspaceVars((prev) => [...prev, { key: '', value: '', id: generateRowId() }]) - setSearchTerm('') - setShouldScrollToBottom(true) + useEffect(() => { + const personalKeys = envVars.map((envVar) => envVar.key.trim()).filter((key) => key.length > 0) + + const uniquePersonalKeys = Array.from(new Set(personalKeys)) + + const computedConflicts = uniquePersonalKeys.filter((key) => Object.hasOwn(workspaceVars, key)) + + setConflicts((prev) => { + if (prev.length === computedConflicts.length) { + const sameKeys = prev.every((key) => computedConflicts.includes(key)) + if (sameKeys) return prev + } + return computedConflicts + }) + }, [envVars, workspaceVars]) + + const handleWorkspaceKeyRename = useCallback( + (currentKey: string, currentValue: string) => { + const newKey = pendingKeyValue.trim() + if (!renamingKey || renamingKey !== currentKey) return + setRenamingKey(null) + if (!newKey || newKey === currentKey) return + + setWorkspaceVars((prev) => { + const next = { ...prev } + delete next[currentKey] + next[newKey] = currentValue + return next + }) + }, + [pendingKeyValue, renamingKey] + ) + + const handleDeleteWorkspaceVar = useCallback((key: string) => { + setWorkspaceVars((prev) => { + const next = { ...prev } + delete next[key] + return next + }) }, []) - const addPersonalVar = useCallback(() => { - setPersonalVars((prev) => [...prev, { key: '', value: '', id: generateRowId() }]) + const addEnvVar = useCallback(() => { + setEnvVars((prev) => [...prev, createEmptyEnvVar()]) setSearchTerm('') setShouldScrollToBottom(true) }, []) - const updateWorkspaceVar = useCallback((index: number, field: 'key' | 'value', value: string) => { - setWorkspaceVars((prev) => { - const updated = [...prev] - updated[index] = { ...updated[index], [field]: value } - return updated + const updateEnvVar = useCallback((index: number, field: 'key' | 'value', value: string) => { + setEnvVars((prev) => { + const newEnvVars = [...prev] + newEnvVars[index][field] = value + return newEnvVars }) }, []) - const updatePersonalVar = useCallback((index: number, field: 'key' | 'value', value: string) => { - setPersonalVars((prev) => { - const updated = [...prev] - updated[index] = { ...updated[index], [field]: value } - return updated + const removeEnvVar = useCallback((index: number) => { + setEnvVars((prev) => { + const newEnvVars = prev.filter((_, i) => i !== index) + return newEnvVars.length ? newEnvVars : [createEmptyEnvVar()] }) }, []) - const removeWorkspaceVar = useCallback((index: number) => { - setWorkspaceVars((prev) => prev.filter((_, i) => i !== index)) + const handleValueFocus = useCallback((index: number, e: React.FocusEvent) => { + setFocusedValueIndex(index) + e.target.scrollLeft = 0 + }, []) + + const handleValueClick = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.currentTarget.scrollLeft = 0 }, []) - const removePersonalVar = useCallback((index: number) => { - setPersonalVars((prev) => prev.filter((_, i) => i !== index)) + const parseEnvVarLine = useCallback((line: string): UIEnvironmentVariable | null => { + const equalIndex = line.indexOf('=') + if (equalIndex === -1 || equalIndex === 0) return null + + const potentialKey = line.substring(0, equalIndex).trim() + if (!ENV_VAR_PATTERN.test(potentialKey)) return null + + const value = line.substring(equalIndex + 1).trim() + return { key: potentialKey, value, id: generateRowId() } }, []) - const promoteToWorkspace = useCallback( - (index: number) => { - const varToPromote = personalVars[index] - if (!varToPromote?.key || !varToPromote?.value) return + const handleSingleValuePaste = useCallback( + (text: string, index: number, inputType: 'key' | 'value') => { + setEnvVars((prev) => { + const newEnvVars = [...prev] + newEnvVars[index][inputType] = text + return newEnvVars + }) + }, + [] + ) - const keyExists = workspaceVars.some((ws) => ws.key === varToPromote.key) - if (keyExists) return + const handleKeyValuePaste = useCallback( + (lines: string[]) => { + const parsedVars = lines + .map(parseEnvVarLine) + .filter((parsed): parsed is UIEnvironmentVariable => parsed !== null) + .filter(({ key, value }) => key && value) + + if (parsedVars.length > 0) { + setEnvVars((prev) => { + const existingVars = prev.filter((v) => v.key || v.value) + return [...existingVars, ...parsedVars] + }) + setShouldScrollToBottom(true) + } + }, + [parseEnvVarLine] + ) + + const handlePaste = useCallback( + (e: React.ClipboardEvent, index: number) => { + const text = e.clipboardData.getData('text').trim() + if (!text) return + + const lines = text.split('\n').filter((line) => line.trim()) + if (lines.length === 0) return + + e.preventDefault() + + const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as + | 'key' + | 'value' + + if (inputType) { + const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null) + if (!hasValidEnvVarPattern) { + handleSingleValuePaste(text, index, inputType) + return + } + } - setWorkspaceVars((prev) => [...prev, { ...varToPromote, id: generateRowId() }]) - setPersonalVars((prev) => prev.filter((_, i) => i !== index)) + handleKeyValuePaste(lines) }, - [personalVars, workspaceVars] + [parseEnvVarLine, handleSingleValuePaste, handleKeyValuePaste] ) const handleCancel = useCallback(() => { - setPersonalVars(structuredClone(initialPersonalVars)) - setWorkspaceVars(structuredClone(initialWorkspaceVars)) + setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current))) + setWorkspaceVars({ ...initialWorkspaceVarsRef.current }) setShowUnsavedChanges(false) + pendingProceedCallback.current?.() pendingProceedCallback.current = null - }, [initialPersonalVars, initialWorkspaceVars]) + }, []) const handleSave = useCallback(async () => { const onProceed = pendingProceedCallback.current - const prevPersonal = [...initialPersonalVars] - const prevWorkspace = [...initialWorkspaceVars] + + const prevInitialVars = [...initialVarsRef.current] + const prevInitialWorkspaceVars = { ...initialWorkspaceVarsRef.current } try { setShowUnsavedChanges(false) hasSavedRef.current = true - const newPersonalInitial = JSON.parse( - JSON.stringify(personalVars.filter((v) => v.key && v.value)) - ) - const newWorkspaceInitial = JSON.parse( - JSON.stringify(workspaceVars.filter((v) => v.key && v.value)) - ) + initialWorkspaceVarsRef.current = { ...workspaceVars } + initialVarsRef.current = JSON.parse(JSON.stringify(envVars.filter((v) => v.key && v.value))) - const personalToSave = personalVars + setChangeToken((prev) => prev + 1) + + const validVariables = envVars .filter((v) => v.key && v.value) .reduce>((acc, { key, value }) => ({ ...acc, [key]: value }), {}) - await savePersonalMutation.mutateAsync({ variables: personalToSave }) + await savePersonalMutation.mutateAsync({ variables: validVariables }) - if (workspaceId) { - const currentWsMap = new Map( - workspaceVars.filter((v) => v.key && v.value).map((v) => [v.key, v.value]) - ) - const initialWsMap = new Map( - prevWorkspace.filter((v) => v.key && v.value).map((v) => [v.key, v.value]) - ) - - const toUpsert: Record = {} - const toDelete: string[] = [] - - for (const [k, v] of currentWsMap) { - if (!initialWsMap.has(k) || initialWsMap.get(k) !== v) { - toUpsert[k] = v - } - } + const before = prevInitialWorkspaceVars + const after = workspaceVars + const toUpsert: Record = {} + const toDelete: string[] = [] - for (const k of initialWsMap.keys()) { - if (!currentWsMap.has(k)) { - toDelete.push(k) - } + for (const [k, v] of Object.entries(after)) { + if (!(k in before) || before[k] !== v) { + toUpsert[k] = v } + } + for (const k of Object.keys(before)) { + if (!(k in after)) toDelete.push(k) + } + + if (workspaceId) { if (Object.keys(toUpsert).length) { await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert }) } @@ -412,49 +470,174 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment } } - setInitialPersonalVars(newPersonalInitial) - setInitialWorkspaceVars(newWorkspaceInitial) - setPersonalVars(newPersonalInitial) - setWorkspaceVars(newWorkspaceInitial) - onProceed?.() pendingProceedCallback.current = null } catch (error) { hasSavedRef.current = false - setInitialPersonalVars(prevPersonal) - setInitialWorkspaceVars(prevWorkspace) + initialVarsRef.current = prevInitialVars + initialWorkspaceVarsRef.current = prevInitialWorkspaceVars logger.error('Failed to save environment variables:', error) } }, [ - personalVars, + envVars, workspaceVars, workspaceId, - initialPersonalVars, - initialWorkspaceVars, savePersonalMutation, upsertWorkspaceMutation, removeWorkspaceMutation, ]) - const handleValueFocus = useCallback((index: number, e: React.FocusEvent) => { - setFocusedValueIndex(index) - e.target.scrollLeft = 0 + const promoteToWorkspace = useCallback( + (envVar: UIEnvironmentVariable) => { + if (!envVar.key || !envVar.value || !workspaceId) return + setWorkspaceVars((prev) => ({ ...prev, [envVar.key]: envVar.value })) + setEnvVars((prev) => { + const filtered = prev.filter((entry) => entry !== envVar) + return filtered.length ? filtered : [createEmptyEnvVar()] + }) + }, + [workspaceId] + ) + + const demoteToPersonal = useCallback((key: string, value: string) => { + if (!key) return + setWorkspaceVars((prev) => { + const next = { ...prev } + delete next[key] + return next + }) + setEnvVars((prev) => [...prev, { key, value, id: generateRowId() }]) }, []) + const conflictClassName = 'border-[var(--text-error)] bg-[#F6D2D2] dark:bg-[#442929]' + + const renderEnvVarRow = useCallback( + (envVar: UIEnvironmentVariable, originalIndex: number) => { + const isConflict = !!envVar.key && Object.hasOwn(workspaceVars, envVar.key) + const maskedValueStyle = + focusedValueIndex !== originalIndex && !isConflict + ? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties) + : undefined + + return ( + <> +
+ updateEnvVar(originalIndex, 'key', e.target.value)} + onPaste={(e) => handlePaste(e, originalIndex)} + placeholder='API_KEY' + name={`env_variable_name_${envVar.id || originalIndex}_${Math.random()}`} + autoComplete='off' + autoCapitalize='off' + spellCheck='false' + readOnly + onFocus={(e) => e.target.removeAttribute('readOnly')} + className={`h-9 ${isConflict ? conflictClassName : ''}`} + /> +
+ updateEnvVar(originalIndex, 'value', e.target.value)} + type='text' + onFocus={(e) => { + if (!isConflict) { + e.target.removeAttribute('readOnly') + handleValueFocus(originalIndex, e) + } + }} + onClick={handleValueClick} + onBlur={() => setFocusedValueIndex(null)} + onPaste={(e) => handlePaste(e, originalIndex)} + placeholder={isConflict ? 'Workspace override active' : 'Enter value'} + disabled={isConflict} + aria-disabled={isConflict} + name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`} + autoComplete='off' + autoCapitalize='off' + spellCheck='false' + readOnly={isConflict} + style={maskedValueStyle} + className={`h-9 ${isConflict ? `cursor-not-allowed ${conflictClassName}` : ''}`} + /> +
+ + + + + Change to workspace scope + + + + + + Delete environment variable + +
+
+ {isConflict && ( +
+ Workspace variable with the same name overrides this. Rename your personal key to use + it. +
+ )} + + ) + }, + [ + workspaceVars, + workspaceId, + focusedValueIndex, + updateEnvVar, + handlePaste, + handleValueFocus, + handleValueClick, + promoteToWorkspace, + removeEnvVar, + ] + ) + return ( <>
- + +
-
setSearchTerm(e.target.value)} - name='env_search' + name='env_search_field' autoComplete='off' autoCapitalize='off' spellCheck='false' @@ -474,35 +657,32 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' />
+ - {(hasConflicts || hasDuplicateWorkspaceKeys || hasDuplicatePersonalKeys) && ( - - {hasDuplicateWorkspaceKeys || hasDuplicatePersonalKeys - ? 'Remove duplicate keys before saving' - : 'Resolve all conflicts before saving'} - - )} + {hasConflicts && Resolve all conflicts before saving}
-
+
{isLoading ? ( <>
@@ -528,104 +708,54 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment ) : ( <> -
-
- + {(!searchTerm.trim() || filteredWorkspaceEntries.length > 0) && ( +
+
Workspace - - -
- {filteredWorkspaceVars.length === 0 && !searchTerm.trim() ? ( -
- No workspace variables yet
- ) : ( - filteredWorkspaceVars.map((v, idx) => { - const originalIndex = workspaceVars.findIndex((wv) => wv.id === v.id) - const isNew = !initialWorkspaceVars.some( - (iv) => iv.key === v.key && iv.value === v.value - ) - return ( - updateWorkspaceVar(originalIndex, 'key', val)} - onValueChange={(val) => updateWorkspaceVar(originalIndex, 'value', val)} - onValueFocus={handleValueFocus} - onValueBlur={() => setFocusedValueIndex(null)} - onDelete={() => removeWorkspaceVar(originalIndex)} + {!searchTerm.trim() && Object.keys(workspaceVars).length === 0 ? ( +
+ No workspace variables yet +
+ ) : ( + (searchTerm.trim() + ? filteredWorkspaceEntries + : Object.entries(workspaceVars) + ).map(([key, value]) => ( + - ) - }) - )} -
+ )) + )} +
+ )} -
-
-
- - Personal - - - - - - - Private to you and for testing purposes. Used solely for manual runs - unless you are the workflow owner. - - + {(!searchTerm.trim() || filteredEnvVars.length > 0) && ( +
+
+ Personal
- + {filteredEnvVars.map(({ envVar, originalIndex }) => ( +
+ {renderEnvVarRow(envVar, originalIndex)} +
+ ))}
- {filteredPersonalVars.length === 0 && !searchTerm.trim() ? ( -
- No personal variables yet -
- ) : ( - filteredPersonalVars.map((v) => { - const originalIndex = personalVars.findIndex((pv) => pv.id === v.id) - const isConflict = Boolean(v.key && workspaceKeysSet.has(v.key)) - return ( - updatePersonalVar(originalIndex, 'key', val)} - onValueChange={(val) => updatePersonalVar(originalIndex, 'value', val)} - onValueFocus={handleValueFocus} - onValueBlur={() => setFocusedValueIndex(null)} - onDelete={() => removePersonalVar(originalIndex)} - onPromote={() => promoteToWorkspace(originalIndex)} - canPromote={!!v.key && !!v.value && !isConflict && !!workspaceId} - isConflict={isConflict} - /> - ) - }) - )} -
- + )} {searchTerm.trim() && - filteredPersonalVars.length === 0 && - filteredWorkspaceVars.length === 0 && ( + filteredEnvVars.length === 0 && + filteredWorkspaceEntries.length === 0 && + (envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
No environment variables found matching "{searchTerm}"
@@ -653,7 +783,11 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment {hasConflicts ? ( -