diff --git a/src/components/ide/CodeEditor.tsx b/src/components/ide/CodeEditor.tsx index 138e5b5d..e290cc65 100644 --- a/src/components/ide/CodeEditor.tsx +++ b/src/components/ide/CodeEditor.tsx @@ -23,6 +23,7 @@ import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { RichTextComposer } from "./RichTextComposer"; import { AdvancedWorkbench } from "./AdvancedWorkbench"; +import { EnvFileEditor } from "./EnvFileEditor"; import { richTextToPlainText, sanitizeRichText } from "@/lib/richText"; import { useCollaboration } from "@/hooks/useCollaboration"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -542,10 +543,12 @@ export const CodeEditor = ({ ); } + const isEnvFile = file.name === ".env" || file.name.startsWith(".env."); const previewType = getPreviewType(file.name); const binaryPreviewTypes = ["image", "video", "audio", "cad", "rtf"]; const isTextPreviewable = previewType && !binaryPreviewTypes.includes(previewType); + if (isEnvFile) return ; if (previewType === "office") return ; if (previewType === "video") return ; if (previewType === "audio") return ; diff --git a/src/components/ide/EnvFileEditor.tsx b/src/components/ide/EnvFileEditor.tsx new file mode 100644 index 00000000..3aa848f2 --- /dev/null +++ b/src/components/ide/EnvFileEditor.tsx @@ -0,0 +1,441 @@ +import { useEffect, useMemo, useState } from 'react'; +import { FileNode } from '@/types/ide'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { icons, LucideIcon, FolderPlus, Plus, Trash2 } from 'lucide-react'; + +interface EnvFileEditorProps { + file: FileNode; + onContentChange: (fileId: string, content: string) => void; +} + +type EnvFolder = { + id: string; + name: string; + color: string; + icon: string; +}; + +type EnvSecret = { + id: string; + key: string; + value: string; + name: string; + color: string; + icon: string; + folderId: string; +}; + +const META_PREFIX = '# code-canvas:'; +const DEFAULT_FOLDER_ID = 'unassigned'; +const ICON_OPTIONS = ['Key', 'Lock', 'Shield', 'Database', 'Globe', 'Server', 'Cloud', 'Folder', 'FolderLock', 'Package']; + +const safeJsonParse = (value: string): T | null => { + try { + return JSON.parse(value) as T; + } catch { + return null; + } +}; + +const createId = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `env-${Math.random().toString(36).slice(2, 10)}`; +}; + +const parseEnvFile = (content: string): { folders: EnvFolder[]; secrets: EnvSecret[] } => { + const lines = content.split('\n'); + const folders = new Map(); + const secrets: EnvSecret[] = []; + + let pendingSecretMeta: Partial | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith(`${META_PREFIX}folder`)) { + const payload = trimmed.replace(`${META_PREFIX}folder`, '').trim(); + const meta = safeJsonParse>(payload); + if (meta?.id) { + folders.set(meta.id, { + id: meta.id, + name: meta.name || 'Folder', + color: meta.color || '#6366f1', + icon: meta.icon || 'Folder', + }); + } + continue; + } + + if (trimmed.startsWith(`${META_PREFIX}secret`)) { + const payload = trimmed.replace(`${META_PREFIX}secret`, '').trim(); + pendingSecretMeta = safeJsonParse>(payload) || null; + continue; + } + + if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue; + + const separatorIndex = line.indexOf('='); + const key = line.slice(0, separatorIndex).trim(); + const value = line.slice(separatorIndex + 1); + if (!key) continue; + + const secret: EnvSecret = { + id: createId(), + key, + value, + name: pendingSecretMeta?.name || key, + color: pendingSecretMeta?.color || '#64748b', + icon: pendingSecretMeta?.icon || 'Key', + folderId: pendingSecretMeta?.folderId || DEFAULT_FOLDER_ID, + }; + + secrets.push(secret); + pendingSecretMeta = null; + } + + return { folders: Array.from(folders.values()), secrets }; +}; + +const serializeEnvFile = (folders: EnvFolder[], secrets: EnvSecret[]) => { + const lines: string[] = []; + + folders.forEach((folder) => { + lines.push(`${META_PREFIX}folder ${JSON.stringify(folder)}`); + }); + + const grouped = [...secrets].sort((a, b) => { + if (a.folderId !== b.folderId) return a.folderId.localeCompare(b.folderId); + return a.name.localeCompare(b.name); + }); + + grouped.forEach((secret) => { + const secretMeta = { + name: secret.name, + folderId: secret.folderId, + color: secret.color, + icon: secret.icon, + }; + lines.push(`${META_PREFIX}secret ${JSON.stringify(secretMeta)}`); + lines.push(`${secret.key}=${secret.value}`); + }); + + return `${lines.join('\n')}\n`; +}; + +const iconForName = (name: string): LucideIcon => { + const Icon = (icons as Record)[name]; + return Icon || icons.Key; +}; + +export const EnvFileEditor = ({ file, onContentChange }: EnvFileEditorProps) => { + const [folders, setFolders] = useState([]); + const [secrets, setSecrets] = useState([]); + + useEffect(() => { + const parsed = parseEnvFile(file.content || ''); + setFolders(parsed.folders); + setSecrets(parsed.secrets); + }, [file.id, file.content]); + + const persist = (nextFolders: EnvFolder[], nextSecrets: EnvSecret[]) => { + setFolders(nextFolders); + setSecrets(nextSecrets); + onContentChange(file.id, serializeEnvFile(nextFolders, nextSecrets)); + }; + + const folderOptions = useMemo( + () => [{ id: DEFAULT_FOLDER_ID, name: 'No folder', color: '#334155', icon: 'Folder' }, ...folders], + [folders], + ); + + const groupedSecrets = useMemo(() => { + const groups = new Map(); + secrets.forEach((secret) => { + const key = secret.folderId || DEFAULT_FOLDER_ID; + const existing = groups.get(key) || []; + existing.push(secret); + groups.set(key, existing); + }); + return groups; + }, [secrets]); + + const addFolder = () => { + const nextFolders = [ + ...folders, + { id: createId(), name: `Folder ${folders.length + 1}`, color: '#6366f1', icon: 'Folder' }, + ]; + persist(nextFolders, secrets); + }; + + const addSecret = () => { + const nextSecrets = [ + ...secrets, + { + id: createId(), + key: 'NEW_SECRET', + value: '', + name: 'New Secret', + color: '#64748b', + icon: 'Key', + folderId: DEFAULT_FOLDER_ID, + }, + ]; + persist(folders, nextSecrets); + }; + + return ( +
+
+
+

.env Visual Manager

+

Add key/value secrets with labels, icons, colors, and folders.

+
+
+ + +
+
+ +
+ + + Folders + + + +
+ {folders.map((folder) => { + const Icon = iconForName(folder.icon); + return ( +
+
+
+ +
+ { + const nextFolders = folders.map((item) => (item.id === folder.id ? { ...item, name: e.target.value } : item)); + persist(nextFolders, secrets); + }} + className="h-7 text-xs" + /> + +
+ +
+ + { + const nextFolders = folders.map((item) => (item.id === folder.id ? { ...item, color: e.target.value } : item)); + persist(nextFolders, secrets); + }} + className="h-7 w-10 p-1" + /> +
+
+ ); + })} + {folders.length === 0 &&

No folders yet. Create one to organize secrets.

} +
+
+
+
+ + + + Secrets + + + +
+ {folderOptions.map((folder) => { + const folderSecrets = groupedSecrets.get(folder.id) || []; + if (folderSecrets.length === 0) return null; + const FolderIcon = iconForName(folder.icon); + + return ( +
+
+ +

{folder.name}

+ + {folderSecrets.length} + +
+ +
+ {folderSecrets.map((secret) => { + const SecretIcon = iconForName(secret.icon); + return ( +
+
+
+
+ +
+ { + const nextSecrets = secrets.map((item) => + item.id === secret.id ? { ...item, name: e.target.value } : item, + ); + persist(folders, nextSecrets); + }} + className="h-7 w-52 text-xs" + /> +
+ +
+ +
+
+ + { + const nextSecrets = secrets.map((item) => + item.id === secret.id ? { ...item, key: e.target.value.toUpperCase().replace(/\s+/g, '_') } : item, + ); + persist(folders, nextSecrets); + }} + className="h-8 font-mono text-xs" + /> +
+
+ + { + const nextSecrets = secrets.map((item) => + item.id === secret.id ? { ...item, value: e.target.value } : item, + ); + persist(folders, nextSecrets); + }} + className="h-8 font-mono text-xs" + /> +
+
+ +
+ + + + + { + const nextSecrets = secrets.map((item) => + item.id === secret.id ? { ...item, color: e.target.value } : item, + ); + persist(folders, nextSecrets); + }} + className="h-7 p-1" + /> +
+
+ ); + })} +
+
+ ); + })} + + {secrets.length === 0 &&

No secrets yet. Click Secret to add your first entry.

} +
+
+
+
+
+
+ ); +};