diff --git a/app/w/components/sidebar/components/settings-modal/components/environment-variables/environment-variables.tsx b/app/w/components/sidebar/components/settings-modal/components/environment-variables/environment-variables.tsx new file mode 100644 index 000000000..b8930324c --- /dev/null +++ b/app/w/components/sidebar/components/settings-modal/components/environment-variables/environment-variables.tsx @@ -0,0 +1,268 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useEnvironmentStore } from '@/stores/environment/store' +import { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/environment/types' + +// Extend the store type with our UI-specific fields +interface UIEnvironmentVariable extends StoreEnvironmentVariable { + id?: number +} + +interface EnvironmentVariablesProps { + onOpenChange: (open: boolean) => void +} + +const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),40px] gap-4' +const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' } + +export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps) { + const { variables, setVariable, removeVariable } = useEnvironmentStore() + const [envVars, setEnvVars] = useState([]) + const [focusedValueIndex, setFocusedValueIndex] = useState(null) + const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) + const scrollContainerRef = useRef(null) + const pendingClose = useRef(false) + const initialVarsRef = useRef([]) + + // Check if there are unsaved changes by comparing with initial state + const hasChanges = useMemo(() => { + 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) { + const initialValue = initialMap.get(key) + if (initialValue !== value) return true + } + + for (const key of initialMap.keys()) { + if (!currentMap.has(key)) return true + } + + return false + }, [envVars]) + + // Initialize environment variables + useEffect(() => { + const existingVars = Object.values(variables) + const initialVars = existingVars.length ? existingVars : [INITIAL_ENV_VAR] + initialVarsRef.current = JSON.parse(JSON.stringify(initialVars)) + setEnvVars(JSON.parse(JSON.stringify(initialVars))) + pendingClose.current = false + }, [variables]) + + const handleClose = () => { + if (hasChanges) { + setShowUnsavedChanges(true) + pendingClose.current = true + } else { + onOpenChange(false) + } + } + + const handleCancel = () => { + setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current))) + setShowUnsavedChanges(false) + if (pendingClose.current) { + onOpenChange(false) + } + } + + useEffect(() => { + if (scrollContainerRef.current) { + // Smooth scroll to bottom when new variables are added + scrollContainerRef.current.scrollTo({ + top: scrollContainerRef.current.scrollHeight, + behavior: 'smooth', + }) + } + }, [envVars.length]) // Only trigger on length changes + + const handleValueFocus = (index: number, e: React.FocusEvent) => { + setFocusedValueIndex(index) + e.target.scrollLeft = 0 + } + + const handleValueClick = (e: React.MouseEvent) => { + e.preventDefault() + e.currentTarget.scrollLeft = 0 + } + + const handlePaste = (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' + const containsKeyValuePair = text.includes('=') + + if (inputType && !containsKeyValuePair) { + handleSingleValuePaste(text, index, inputType) + return + } + + handleKeyValuePaste(lines) + } + + const handleSingleValuePaste = (text: string, index: number, inputType: 'key' | 'value') => { + const newEnvVars = [...envVars] + newEnvVars[index][inputType] = text + setEnvVars(newEnvVars) + } + + const handleKeyValuePaste = (lines: string[]) => { + const parsedVars = lines + .map((line) => { + const [key, ...valueParts] = line.split('=') + const value = valueParts.join('=').trim() + return { + key: key.trim(), + value, + id: Date.now() + Math.random(), + } + }) + .filter(({ key, value }) => key && value) + + if (parsedVars.length > 0) { + const existingVars = envVars.filter((v) => v.key || v.value) + setEnvVars([...existingVars, ...parsedVars]) + } + } + + const addEnvVar = () => { + const newVar = { key: '', value: '', id: Date.now() } + setEnvVars([...envVars, newVar]) + } + + const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => { + const newEnvVars = [...envVars] + newEnvVars[index][field] = value + setEnvVars(newEnvVars) + } + + const removeEnvVar = (index: number) => { + const newEnvVars = envVars.filter((_, i) => i !== index) + setEnvVars(newEnvVars.length ? newEnvVars : [INITIAL_ENV_VAR]) + } + + const handleSave = () => { + const validVars = envVars.filter((v) => v.key && v.value) + validVars.forEach((v) => setVariable(v.key, v.value)) + + const currentKeys = new Set(validVars.map((v) => v.key)) + Object.keys(variables).forEach((key) => { + if (!currentKeys.has(key)) { + removeVariable(key) + } + }) + + setShowUnsavedChanges(false) + onOpenChange(false) + } + + const renderEnvVarRow = (envVar: UIEnvironmentVariable, index: number) => ( +
+ updateEnvVar(index, 'key', e.target.value)} + onPaste={(e) => handlePaste(e, index)} + placeholder="e.g. API_KEY" + /> + updateEnvVar(index, 'value', e.target.value)} + type={focusedValueIndex === index ? 'text' : 'password'} + onFocus={(e) => handleValueFocus(index, e)} + onClick={handleValueClick} + onBlur={() => setFocusedValueIndex(null)} + onPaste={(e) => handlePaste(e, index)} + placeholder="Enter value" + className="allow-scroll" + /> + +
+ ) + + return ( +
+ {/* Fixed Header */} +
+

Environment Variables

+
+ + +
+
+
+ + {/* Scrollable Content */} +
+
{envVars.map(renderEnvVarRow)}
+
+ + {/* Fixed Footer */} +
+
+ + +
+ + +
+
+
+ + + + + Unsaved Changes + + You have unsaved changes. Do you want to save them before closing? + + + + Discard Changes + Save Changes + + + +
+ ) +} diff --git a/app/w/components/sidebar/components/settings-modal/components/general/general.tsx b/app/w/components/sidebar/components/settings-modal/components/general/general.tsx new file mode 100644 index 000000000..0cac2f53f --- /dev/null +++ b/app/w/components/sidebar/components/settings-modal/components/general/general.tsx @@ -0,0 +1,26 @@ +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' + +export function General() { + return ( +
+
+

General Settings

+
+
+ + +
+
+ + +
+
+
+
+ ) +} diff --git a/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx b/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx new file mode 100644 index 000000000..714369b18 --- /dev/null +++ b/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx @@ -0,0 +1,43 @@ +import { KeyRound, Settings } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface SettingsNavigationProps { + activeSection: string + onSectionChange: (section: 'general' | 'environment') => void +} + +const navigationItems = [ + { + id: 'general', + label: 'General', + icon: Settings, + }, + { + id: 'environment', + label: 'Environment', + icon: KeyRound, + }, +] as const + +export function SettingsNavigation({ activeSection, onSectionChange }: SettingsNavigationProps) { + return ( +
+ {navigationItems.map((item) => ( + + ))} +
+ ) +} diff --git a/app/w/components/sidebar/components/settings-modal/settings-modal.tsx b/app/w/components/sidebar/components/settings-modal/settings-modal.tsx index 12e09f8b8..5790d11b5 100644 --- a/app/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/app/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -1,301 +1,59 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' +import { useState } from 'react' +import { X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { useEnvironmentStore } from '@/stores/environment/store' -import { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/environment/types' - -// Extend the store type with our UI-specific fields -interface UIEnvironmentVariable extends StoreEnvironmentVariable { - id?: number -} +import { cn } from '@/lib/utils' +import { EnvironmentVariables } from './components/environment-variables/environment-variables' +import { General } from './components/general/general' +import { SettingsNavigation } from './components/settings-navigation/settings-navigation' interface SettingsModalProps { open: boolean onOpenChange: (open: boolean) => void } -const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),40px] gap-4' -const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' } +type SettingsSection = 'general' | 'environment' export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { - const { variables, setVariable, removeVariable } = useEnvironmentStore() - const [envVars, setEnvVars] = useState([]) - const [focusedValueIndex, setFocusedValueIndex] = useState(null) - const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) - const scrollContainerRef = useRef(null) - const pendingClose = useRef(false) - const initialVarsRef = useRef([]) - - // Check if there are unsaved changes by comparing with initial state - const hasChanges = useMemo(() => { - // Filter out empty rows from both initial and current state - const initialVars = initialVarsRef.current.filter((v) => v.key || v.value) - const currentVars = envVars.filter((v) => v.key || v.value) - - // Create maps for easier comparison - const initialMap = new Map(initialVars.map((v) => [v.key, v.value])) - const currentMap = new Map(currentVars.map((v) => [v.key, v.value])) - - // Different number of non-empty variables - if (initialMap.size !== currentMap.size) return true - - // Check for any differences in keys or values - for (const [key, value] of currentMap) { - const initialValue = initialMap.get(key) - // If key doesn't exist in initial or value is different - if (initialValue !== value) { - return true - } - } - - // Check if any initial keys are missing from current - for (const key of initialMap.keys()) { - if (!currentMap.has(key)) { - return true - } - } - - return false - }, [envVars]) - - // Reset state when modal is opened - useEffect(() => { - if (open) { - const existingVars = Object.values(variables) - const initialVars = existingVars.length ? existingVars : [INITIAL_ENV_VAR] - // Create deep copy of initial vars to prevent reference issues - initialVarsRef.current = JSON.parse(JSON.stringify(initialVars)) - setEnvVars(JSON.parse(JSON.stringify(initialVars))) - pendingClose.current = false - } - }, [open, variables]) - - const handleClose = () => { - if (hasChanges) { - setShowUnsavedChanges(true) - pendingClose.current = true - } else { - onOpenChange(false) - } - } - - const handleCancel = () => { - // Reset to initial state when cancelling (without saving) - setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current))) - setShowUnsavedChanges(false) - if (pendingClose.current) { - onOpenChange(false) - } - } - - useEffect(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight - } - }, [envVars]) - - const handleValueFocus = (index: number, e: React.FocusEvent) => { - setFocusedValueIndex(index) - // Always scroll to the start of the input - e.target.scrollLeft = 0 - } - - const handleValueClick = (e: React.MouseEvent) => { - e.preventDefault() - // Always scroll to the start of the input - e.currentTarget.scrollLeft = 0 - } - - const handleValueKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { - e.preventDefault() - } - } - - const handlePaste = (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' - const containsKeyValuePair = text.includes('=') - - // Handle single value paste into specific field - if (inputType && !containsKeyValuePair) { - handleSingleValuePaste(text, index, inputType) - return - } - - // Handle key-value pair(s) paste - handleKeyValuePaste(lines) - } - - const handleSingleValuePaste = (text: string, index: number, inputType: 'key' | 'value') => { - const newEnvVars = [...envVars] - newEnvVars[index][inputType] = text - setEnvVars(newEnvVars) - } - - const handleKeyValuePaste = (lines: string[]) => { - const parsedVars = lines - .map((line) => { - const [key, ...valueParts] = line.split('=') - const value = valueParts.join('=').trim() - return { - key: key.trim(), - value, - id: Date.now() + Math.random(), - } - }) - .filter(({ key, value }) => key && value) - - if (parsedVars.length > 0) { - // Merge existing vars with new ones, removing any empty rows at the end - const existingVars = envVars.filter((v) => v.key || v.value) - setEnvVars([...existingVars, ...parsedVars]) - } - } - - const addEnvVar = () => { - // Create a fresh empty variable with a unique id - const newVar = { key: '', value: '', id: Date.now() } - setEnvVars([...envVars, newVar]) - } - - const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => { - const newEnvVars = [...envVars] - newEnvVars[index][field] = value - setEnvVars(newEnvVars) - } - - const removeEnvVar = (index: number) => { - const newEnvVars = envVars.filter((_, i) => i !== index) - setEnvVars(newEnvVars.length ? newEnvVars : [INITIAL_ENV_VAR]) - } - - const handleSave = () => { - // Save all valid environment variables to the store - const validVars = envVars.filter((v) => v.key && v.value) - validVars.forEach((v) => setVariable(v.key, v.value)) - - // Remove any variables that were deleted - const currentKeys = new Set(validVars.map((v) => v.key)) - Object.keys(variables).forEach((key) => { - if (!currentKeys.has(key)) { - removeVariable(key) - } - }) - - // Close both the alert dialog and the main modal - setShowUnsavedChanges(false) - onOpenChange(false) - } - - const renderEnvVarRow = (envVar: UIEnvironmentVariable, index: number) => ( -
- updateEnvVar(index, 'key', e.target.value)} - onPaste={(e) => handlePaste(e, index)} - placeholder="e.g. API_KEY" - /> - updateEnvVar(index, 'value', e.target.value)} - type={focusedValueIndex === index ? 'text' : 'password'} - onFocus={(e) => handleValueFocus(index, e)} - onClick={handleValueClick} - onBlur={() => setFocusedValueIndex(null)} - onPaste={(e) => handlePaste(e, index)} - placeholder="Enter value" - className="allow-scroll" - /> - -
- ) + const [activeSection, setActiveSection] = useState('general') return ( - <> - - - - Environment Variables - + + + +
+ Settings + +
+
-
-
-
- - -
-
+
+ {/* Navigation Sidebar */} +
+ +
-
-
- {envVars.map(renderEnvVarRow)} -
-
+ {/* Content Area */} +
+
+
- -
- - -
- - -
+
+
- -
- - - - - Unsaved Changes - - You have unsaved changes. Do you want to save them before closing? - - - - Discard Changes - Save Changes - - - - +
+ + ) } diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 044ef6501..85c7287ed 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef< , - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { hideCloseButton?: boolean } +>(({ className, children, hideCloseButton = false, ...props }, ref) => ( {children} - - - Close - + {!hideCloseButton && ( + + + Close + + )} ))