From 8f3404e14a94699f8ad47ead3302493f071c21e6 Mon Sep 17 00:00:00 2001 From: Derssa Date: Tue, 16 Jun 2026 12:28:51 -0400 Subject: [PATCH 001/315] feat: add light-themed searchable Linux Cheat Sheet tab inside Terminal Modal --- .../terminal/components/CommandCard.tsx | 156 +++++++++++ .../terminal/components/LinuxCheatSheet.tsx | 135 ++++++++++ .../terminal/components/TerminalModal.tsx | 75 +++++- .../features/terminal/data/linuxCommands.json | 247 ++++++++++++++++++ .../terminal/hooks/useCommandSearch.ts | 56 ++++ 5 files changed, 659 insertions(+), 10 deletions(-) create mode 100644 frontend/src/features/terminal/components/CommandCard.tsx create mode 100644 frontend/src/features/terminal/components/LinuxCheatSheet.tsx create mode 100644 frontend/src/features/terminal/data/linuxCommands.json create mode 100644 frontend/src/features/terminal/hooks/useCommandSearch.ts diff --git a/frontend/src/features/terminal/components/CommandCard.tsx b/frontend/src/features/terminal/components/CommandCard.tsx new file mode 100644 index 0000000..4cfb9f0 --- /dev/null +++ b/frontend/src/features/terminal/components/CommandCard.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronUp, Copy, Check } from 'lucide-react'; +import type { LinuxCommand } from '../hooks/useCommandSearch'; + +interface CommandCardProps { + command: LinuxCommand; +} + +export default function CommandCard({ command }: CommandCardProps) { + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(command.example); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
setExpanded(!expanded)} + > +
+
+ {command.name} + {command.category} +
+
+ {command.description} + {expanded ? : } +
+
+ + {expanded && ( +
e.stopPropagation()}> +
+ Example Usage + +
+
+            {command.example}
+          
+
+ )} +
+ ); +} + +const styles: Record = { + card: { + border: '1px solid var(--border-color)', + borderRadius: '8px', + padding: '12px 16px', + marginBottom: '10px', + backgroundColor: '#FFFFFF', + cursor: 'pointer', + transition: 'all 0.2s', + }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: '12px', + flexWrap: 'wrap', + }, + left: { + display: 'flex', + alignItems: 'center', + gap: '10px', + }, + name: { + fontFamily: 'JetBrains Mono, monospace', + fontWeight: 700, + fontSize: '14px', + color: 'var(--color-accent)', + backgroundColor: 'var(--color-accent-glow)', + padding: '2px 8px', + borderRadius: '4px', + }, + category: { + fontSize: '11px', + color: 'var(--color-text-secondary)', + backgroundColor: 'rgba(0, 0, 0, 0.04)', + border: '1px solid var(--border-color)', + padding: '2px 6px', + borderRadius: '4px', + textTransform: 'uppercase', + letterSpacing: '0.5px', + }, + right: { + display: 'flex', + alignItems: 'center', + gap: '12px', + flex: 1, + justifyContent: 'space-between', + minWidth: '240px', + }, + desc: { + fontSize: '13px', + color: 'var(--color-text-primary)', + lineHeight: '1.4', + }, + details: { + marginTop: '12px', + paddingTop: '12px', + borderTop: '1px solid var(--border-color)', + }, + exampleHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '6px', + }, + exampleTitle: { + fontSize: '11px', + fontWeight: 600, + color: 'var(--color-text-muted)', + textTransform: 'uppercase', + }, + copyBtn: { + background: 'none', + border: 'none', + color: 'var(--color-text-muted)', + cursor: 'pointer', + fontSize: '11px', + display: 'flex', + alignItems: 'center', + padding: '4px 6px', + borderRadius: '4px', + transition: 'background-color 0.2s', + }, + codeBlock: { + margin: 0, + padding: '10px 14px', + backgroundColor: 'var(--bg-main)', + color: 'var(--color-text-primary)', + fontFamily: 'var(--font-mono)', + borderRadius: '6px', + overflowX: 'auto', + border: '1px solid var(--border-color)', + }, +}; diff --git a/frontend/src/features/terminal/components/LinuxCheatSheet.tsx b/frontend/src/features/terminal/components/LinuxCheatSheet.tsx new file mode 100644 index 0000000..9a23bfe --- /dev/null +++ b/frontend/src/features/terminal/components/LinuxCheatSheet.tsx @@ -0,0 +1,135 @@ +import { useCommandSearch } from '../hooks/useCommandSearch'; +import CommandCard from './CommandCard'; +import { Search } from 'lucide-react'; + +export default function LinuxCheatSheet() { + const { + searchQuery, + setSearchQuery, + selectedCategory, + setSelectedCategory, + categories, + filteredCommands, + } = useCommandSearch(); + + return ( +
+
+
+ + setSearchQuery(e.target.value)} + style={styles.searchInput} + /> +
+
+ +
+ + {categories.map(cat => ( + + ))} +
+ +
+ {filteredCommands.length > 0 ? ( + filteredCommands.map(cmd => ( + + )) + ) : ( +
+ No matching commands found. +
+ )} +
+
+ ); +} + +const styles: Record = { + container: { + display: 'flex', + flexDirection: 'column', + height: '100%', + backgroundColor: '#FFFFFF', + boxSizing: 'border-box', + padding: '16px', + overflowY: 'hidden', + }, + searchBar: { + marginBottom: '12px', + }, + inputWrapper: { + position: 'relative', + display: 'flex', + alignItems: 'center', + }, + searchIcon: { + position: 'absolute', + left: '12px', + }, + searchInput: { + width: '100%', + padding: '10px 12px 10px 36px', + backgroundColor: 'var(--bg-main)', + border: '1px solid var(--border-color)', + borderRadius: '8px', + color: 'var(--color-text-primary)', + fontSize: '13px', + outline: 'none', + transition: 'all 0.2s', + }, + categories: { + display: 'flex', + gap: '6px', + overflowX: 'auto', + paddingBottom: '8px', + marginBottom: '12px', + flexShrink: 0, + }, + categoryBtn: { + border: 'none', + padding: '6px 12px', + borderRadius: '6px', + fontSize: '12px', + fontWeight: 500, + cursor: 'pointer', + whiteSpace: 'nowrap', + transition: 'all 0.2s', + }, + list: { + flex: 1, + overflowY: 'auto', + paddingRight: '4px', + }, + empty: { + textAlign: 'center', + padding: '40px 0', + }, + emptyText: { + color: 'var(--color-text-muted)', + fontSize: '13px', + }, +}; diff --git a/frontend/src/features/terminal/components/TerminalModal.tsx b/frontend/src/features/terminal/components/TerminalModal.tsx index f4c6035..411b9c3 100644 --- a/frontend/src/features/terminal/components/TerminalModal.tsx +++ b/frontend/src/features/terminal/components/TerminalModal.tsx @@ -1,9 +1,10 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { io, Socket } from 'socket.io-client'; -import { X, Terminal as TermIcon } from 'lucide-react'; +import { X, Terminal as TermIcon, BookOpen } from 'lucide-react'; import '@xterm/xterm/css/xterm.css'; +import LinuxCheatSheet from './LinuxCheatSheet'; interface TerminalModalProps { containerId: string; @@ -15,6 +16,7 @@ export default function TerminalModal({ containerId, nodeName, onClose }: Termin const terminalRef = useRef(null); const socketRef = useRef(null); const termRef = useRef(null); + const [activeTab, setActiveTab] = useState<'terminal' | 'cheatsheet'>('terminal'); useEffect(() => { const socket = io('http://localhost:5000'); @@ -73,15 +75,46 @@ export default function TerminalModal({ containerId, nodeName, onClose }: Termin
-
- - Terminal: {nodeName} +
+ +
-
+ +
+ + {activeTab === 'cheatsheet' && ( +
+ +
+ )}
); @@ -117,15 +150,31 @@ const styles: Record = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '12px 16px', + padding: '8px 16px 0 16px', borderBottom: '1px solid var(--border-color)', + backgroundColor: 'rgba(0, 0, 0, 0.1)', }, - title: { + tabs: { + display: 'flex', + gap: '4px', + }, + tabBtn: { + background: 'none', + border: 'none', + borderBottom: '2px solid transparent', + color: 'var(--color-text-secondary)', + cursor: 'pointer', + padding: '8px 16px', + fontSize: '13px', + fontWeight: 500, display: 'flex', alignItems: 'center', + transition: 'all 0.2s', + }, + activeTabBtn: { + color: '#3B82F6', + borderBottomColor: '#3B82F6', fontWeight: 600, - fontSize: '14px', - color: 'var(--color-text-primary)', }, closeBtn: { background: 'none', @@ -137,11 +186,17 @@ const styles: Record = { display: 'flex', alignItems: 'center', justifyContent: 'center', + marginBottom: '8px', }, terminalContainer: { flex: 1, backgroundColor: '#0B0F19', position: 'relative', overflow: 'hidden', + padding: '8px', + }, + contentContainer: { + flex: 1, + overflow: 'hidden', }, }; diff --git a/frontend/src/features/terminal/data/linuxCommands.json b/frontend/src/features/terminal/data/linuxCommands.json new file mode 100644 index 0000000..2ea2596 --- /dev/null +++ b/frontend/src/features/terminal/data/linuxCommands.json @@ -0,0 +1,247 @@ +[ + { + "name": "ls", + "category": "File System", + "description": "List directory contents.", + "example": "ls -la", + "keywords": ["files", "directories", "show", "list", "all"] + }, + { + "name": "cd", + "category": "File System", + "description": "Change the current working directory.", + "example": "cd /var/log", + "keywords": ["directory", "folder", "navigate", "move"] + }, + { + "name": "pwd", + "category": "File System", + "description": "Print the absolute path of the current working directory.", + "example": "pwd", + "keywords": ["path", "location", "where", "directory"] + }, + { + "name": "mkdir", + "category": "File System", + "description": "Create new directories.", + "example": "mkdir -p src/components", + "keywords": ["directory", "folder", "new", "create", "make"] + }, + { + "name": "rm", + "category": "File System", + "description": "Remove files or directories recursively.", + "example": "rm -rf tmp/cache", + "keywords": ["delete", "remove", "erase", "folder", "file"] + }, + { + "name": "cp", + "category": "File System", + "description": "Copy files and directories.", + "example": "cp -r src/ dist/", + "keywords": ["duplicate", "copy", "backup", "folder", "file"] + }, + { + "name": "mv", + "category": "File System", + "description": "Move or rename files and directories.", + "example": "mv old_name.txt new_name.txt", + "keywords": ["rename", "move", "transfer", "cut"] + }, + { + "name": "find", + "category": "File System", + "description": "Search for files in a directory hierarchy based on name, size, type, etc.", + "example": "find . -name \"*.log\"", + "keywords": ["search", "locate", "file", "filter"] + }, + { + "name": "chmod", + "category": "Permissions", + "description": "Change the read, write, and execute permissions of files and directories.", + "example": "chmod 755 start.sh", + "keywords": ["permissions", "rights", "executable", "security"] + }, + { + "name": "chown", + "category": "Permissions", + "description": "Change the user and/or group ownership of files and directories.", + "example": "chown -R www-data:www-data /var/www", + "keywords": ["owner", "ownership", "user", "group"] + }, + { + "name": "ps", + "category": "Process Management", + "description": "Report a snapshot of the current active processes.", + "example": "ps aux | grep node", + "keywords": ["process", "tasks", "running", "jobs", "active"] + }, + { + "name": "top", + "category": "Process Management", + "description": "Display dynamic, real-time view of running system processes and CPU/RAM usage.", + "example": "top", + "keywords": ["monitor", "cpu", "memory", "usage", "real-time"] + }, + { + "name": "htop", + "category": "Process Management", + "description": "Interactive and visually enhanced command-line process viewer.", + "example": "htop", + "keywords": ["monitor", "cpu", "memory", "process", "interactive"] + }, + { + "name": "kill", + "category": "Process Management", + "description": "Send a signal to terminate a process by its Process ID (PID).", + "example": "kill -9 1234", + "keywords": ["stop", "force", "terminate", "sigkill", "end"] + }, + { + "name": "curl", + "category": "Networking", + "description": "Transfer data from or to a server using supported network protocols (HTTP, HTTPS, etc.).", + "example": "curl -X POST -H \"Content-Type: application/json\" -d '{\"key\":\"val\"}' http://localhost:5000/api", + "keywords": ["http", "request", "api", "download", "post", "get"] + }, + { + "name": "wget", + "category": "Networking", + "description": "Non-interactive network downloader to retrieve files from servers.", + "example": "wget https://example.com/file.zip", + "keywords": ["download", "fetch", "retrieve", "http", "ftp"] + }, + { + "name": "ping", + "category": "Networking", + "description": "Send ICMP ECHO_REQUEST packets to verify network connectivity to a host.", + "example": "ping -c 4 google.com", + "keywords": ["network", "latency", "host", "online", "reachability"] + }, + { + "name": "netstat", + "category": "Networking", + "description": "Print network connections, routing tables, and interface statistics.", + "example": "netstat -tulpn", + "keywords": ["ports", "connections", "sockets", "listening"] + }, + { + "name": "ip", + "category": "Networking", + "description": "Show / manipulate routing, network devices, interfaces, and tunnels.", + "example": "ip addr show", + "keywords": ["interface", "address", "networking", "routing", "config"] + }, + { + "name": "grep", + "category": "Text Processing", + "description": "Search text for lines matching a regular expression or string query.", + "example": "grep -rn \"TODO\" src/", + "keywords": ["find", "search", "filter", "pattern", "text"] + }, + { + "name": "awk", + "category": "Text Processing", + "description": "Pattern scanning and processing language, highly useful for columns and logs manipulation.", + "example": "awk '{print $1, $3}' access.log", + "keywords": ["columns", "logs", "format", "extract", "parse"] + }, + { + "name": "sed", + "category": "Text Processing", + "description": "Stream editor for filtering and transforming text dynamically.", + "example": "sed -i 's/localhost/127.0.0.1/g' config.js", + "keywords": ["replace", "substitute", "modify", "edit", "regex"] + }, + { + "name": "cat", + "category": "Text Processing", + "description": "Concatenate and display the content of files.", + "example": "cat /etc/hosts", + "keywords": ["view", "show", "print", "display", "file"] + }, + { + "name": "less", + "category": "Text Processing", + "description": "View file content or command output interactively, allowing forward and backward navigation.", + "example": "less syslog.log", + "keywords": ["view", "scroll", "interactive", "pager"] + }, + { + "name": "uname", + "category": "System Info", + "description": "Print detailed system information about the operating system and kernel.", + "example": "uname -a", + "keywords": ["kernel", "os", "release", "architecture"] + }, + { + "name": "df", + "category": "System Info", + "description": "Show the amount of disk space available on file systems.", + "example": "df -h", + "keywords": ["disk", "storage", "space", "free", "usage"] + }, + { + "name": "du", + "category": "System Info", + "description": "Estimate file and directory space usage.", + "example": "du -sh *", + "keywords": ["disk", "size", "folder", "space", "usage"] + }, + { + "name": "free", + "category": "System Info", + "description": "Display the amount of free and used physical memory (RAM) and swap space.", + "example": "free -h", + "keywords": ["memory", "ram", "swap", "free", "usage"] + }, + { + "name": "tar", + "category": "Archiving", + "description": "Archive utility used to combine multiple files into a single tarball, or compress them.", + "example": "tar -czvf backup.tar.gz /var/www", + "keywords": ["compress", "archive", "tarball", "backup", "extract"] + }, + { + "name": "zip", + "category": "Archiving", + "description": "Package and compress (archive) files.", + "example": "zip -r backup.zip src/", + "keywords": ["compress", "archive", "backup"] + }, + { + "name": "unzip", + "category": "Archiving", + "description": "Extract compressed files from a ZIP archive.", + "example": "unzip backup.zip -d target_dir/", + "keywords": ["extract", "uncompress", "decompress", "archive"] + }, + { + "name": "git clone", + "category": "Git", + "description": "Clone a repository into a new directory.", + "example": "git clone https://github.com/Derssa/akal.git", + "keywords": ["git", "clone", "download", "repo"] + }, + { + "name": "git status", + "category": "Git", + "description": "Show the working tree status, showing staged, unstaged, and untracked files.", + "example": "git status", + "keywords": ["git", "changes", "files", "status"] + }, + { + "name": "git commit", + "category": "Git", + "description": "Record changes to the repository by creating a new commit snapshot.", + "example": "git commit -m \"feat: implement new features\"", + "keywords": ["git", "save", "record", "commit"] + }, + { + "name": "git push", + "category": "Git", + "description": "Update remote references using local refs, uploading commits to the remote repo.", + "example": "git push origin main", + "keywords": ["git", "upload", "publish", "sync", "remote"] + } +] diff --git a/frontend/src/features/terminal/hooks/useCommandSearch.ts b/frontend/src/features/terminal/hooks/useCommandSearch.ts new file mode 100644 index 0000000..cc7c7fa --- /dev/null +++ b/frontend/src/features/terminal/hooks/useCommandSearch.ts @@ -0,0 +1,56 @@ +import { useState, useMemo } from 'react'; +import commandsData from '../data/linuxCommands.json'; + +export interface LinuxCommand { + name: string; + category: string; + description: string; + example: string; + keywords: string[]; +} + +export function useCommandSearch() { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(null); + + const categories = useMemo(() => { + const allCategories = commandsData.map(c => c.category); + return Array.from(new Set(allCategories)); + }, []); + + const filteredCommands = useMemo(() => { + let list = commandsData as LinuxCommand[]; + + if (selectedCategory) { + list = list.filter(c => c.category === selectedCategory); + } + + if (searchQuery.trim()) { + const terms = searchQuery.toLowerCase().split(/\s+/).filter(Boolean); + + list = list.filter(cmd => { + const name = cmd.name.toLowerCase(); + const description = cmd.description.toLowerCase(); + const keywords = cmd.keywords.map(k => k.toLowerCase()); + + // Check if every search term matches at least one field (name, desc, or keywords) + return terms.every(term => + name.includes(term) || + description.includes(term) || + keywords.some(k => k.includes(term)) + ); + }); + } + + return list; + }, [searchQuery, selectedCategory]); + + return { + searchQuery, + setSearchQuery, + selectedCategory, + setSelectedCategory, + categories, + filteredCommands, + }; +} From 9f2373a6ff1eb2fad7c227e44b4b51e06e52f178 Mon Sep 17 00:00:00 2001 From: Derssa Date: Tue, 16 Jun 2026 12:33:57 -0400 Subject: [PATCH 002/315] docs: expand Git section with beginner, intermediate, and advanced commands --- .../features/terminal/data/linuxCommands.json | 133 +++++++++++++++++- 1 file changed, 126 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/terminal/data/linuxCommands.json b/frontend/src/features/terminal/data/linuxCommands.json index 2ea2596..5e26243 100644 --- a/frontend/src/features/terminal/data/linuxCommands.json +++ b/frontend/src/features/terminal/data/linuxCommands.json @@ -216,32 +216,151 @@ "example": "unzip backup.zip -d target_dir/", "keywords": ["extract", "uncompress", "decompress", "archive"] }, + { + "name": "git init", + "category": "Git", + "description": "Initialize a new local Git repository in the current directory.", + "example": "git init", + "keywords": ["git", "initialize", "new", "repository", "local", "start"] + }, { "name": "git clone", "category": "Git", - "description": "Clone a repository into a new directory.", + "description": "Clone an existing remote repository into a new local directory.", "example": "git clone https://github.com/Derssa/akal.git", - "keywords": ["git", "clone", "download", "repo"] + "keywords": ["git", "clone", "download", "repo", "copy"] + }, + { + "name": "git add", + "category": "Git", + "description": "Stage files and changes to prepare them for the next commit.", + "example": "git add src/index.css", + "keywords": ["git", "stage", "add", "files", "prepare"] + }, + { + "name": "git diff", + "category": "Git", + "description": "Show differences between the working tree, index, or commits.", + "example": "git diff README.md", + "keywords": ["git", "changes", "compare", "differences", "unstaged"] }, { "name": "git status", "category": "Git", - "description": "Show the working tree status, showing staged, unstaged, and untracked files.", + "description": "Show the status of the working tree (staged, unstaged, untracked files).", "example": "git status", - "keywords": ["git", "changes", "files", "status"] + "keywords": ["git", "changes", "files", "status", "untracked"] }, { "name": "git commit", "category": "Git", - "description": "Record changes to the repository by creating a new commit snapshot.", + "description": "Create a new snapshot commit of the staged changes.", "example": "git commit -m \"feat: implement new features\"", - "keywords": ["git", "save", "record", "commit"] + "keywords": ["git", "save", "record", "commit", "message"] + }, + { + "name": "git log", + "category": "Git", + "description": "Show the list of historical commits for the active branch.", + "example": "git log --oneline -n 5", + "keywords": ["git", "history", "commits", "logs", "past"] + }, + { + "name": "git branch", + "category": "Git", + "description": "List, create, or delete branches.", + "example": "git branch feature/canvas-grid", + "keywords": ["git", "branch", "list", "new", "delete", "create"] + }, + { + "name": "git checkout", + "category": "Git", + "description": "Switch branches or restore working tree files.", + "example": "git checkout dev", + "keywords": ["git", "switch", "branch", "restore", "discard"] + }, + { + "name": "git pull", + "category": "Git", + "description": "Fetch from and integrate (merge/rebase) remote changes with another branch.", + "example": "git pull origin dev", + "keywords": ["git", "pull", "update", "fetch", "sync", "remote"] }, { "name": "git push", "category": "Git", - "description": "Update remote references using local refs, uploading commits to the remote repo.", + "description": "Upload local commits to a remote repository reference.", "example": "git push origin main", "keywords": ["git", "upload", "publish", "sync", "remote"] + }, + { + "name": "git merge", + "category": "Git", + "description": "Join two or more development histories (branches) together.", + "example": "git merge feature/canvas-grid", + "keywords": ["git", "merge", "combine", "join", "integrate"] + }, + { + "name": "git stash", + "category": "Git", + "description": "Stash the changes in a dirty working directory away to return to a clean state.", + "example": "git stash pop", + "keywords": ["git", "stash", "save", "temporary", "restore", "pop"] + }, + { + "name": "git remote", + "category": "Git", + "description": "Manage set of tracked remote repositories.", + "example": "git remote add origin https://github.com/user/repo.git", + "keywords": ["git", "remote", "origin", "links", "url"] + }, + { + "name": "git rebase", + "category": "Git", + "description": "Reapply commits on top of another base tip (advanced).", + "example": "git rebase main", + "keywords": ["git", "rebase", "history", "interactive", "rewrite"] + }, + { + "name": "git cherry-pick", + "category": "Git", + "description": "Apply the changes introduced by some existing commits onto the current branch.", + "example": "git cherry-pick a1b2c3d", + "keywords": ["git", "copy", "commit", "apply", "select"] + }, + { + "name": "git reset", + "category": "Git", + "description": "Reset current HEAD to the specified state (e.g. discard all local changes with --hard).", + "example": "git reset --hard HEAD~1", + "keywords": ["git", "reset", "undo", "hard", "soft", "discard"] + }, + { + "name": "git revert", + "category": "Git", + "description": "Revert an existing commit by creating a new reversing commit (safe history).", + "example": "git revert a1b2c3d", + "keywords": ["git", "undo", "revert", "safe", "new-commit"] + }, + { + "name": "git reflog", + "category": "Git", + "description": "Reference log to manage and recover lost commits or actions.", + "example": "git reflog", + "keywords": ["git", "recovery", "history", "lost", "undo"] + }, + { + "name": "git blame", + "category": "Git", + "description": "Show what revision and author last modified each line of a file.", + "example": "git blame src/index.css", + "keywords": ["git", "author", "line", "modified", "history", "who"] + }, + { + "name": "git merge --squash", + "category": "Git", + "description": "Combine all commits from a feature branch into a single commit upon merging.", + "example": "git merge --squash feature/canvas-grid", + "keywords": ["git", "squash", "combine", "merge", "single-commit"] } ] From 57971608742870125d03d5fa0fa33e9f5a493dd8 Mon Sep 17 00:00:00 2001 From: Derssa Date: Wed, 17 Jun 2026 12:52:52 -0400 Subject: [PATCH 003/315] style: update terminal modal styles, hide terminal scrollbars, and apply custom global scrollbars --- .../terminal/components/LinuxCheatSheet.tsx | 2 +- .../terminal/components/TerminalModal.tsx | 15 +++++---- frontend/src/index.css | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/terminal/components/LinuxCheatSheet.tsx b/frontend/src/features/terminal/components/LinuxCheatSheet.tsx index 9a23bfe..271d500 100644 --- a/frontend/src/features/terminal/components/LinuxCheatSheet.tsx +++ b/frontend/src/features/terminal/components/LinuxCheatSheet.tsx @@ -112,7 +112,7 @@ const styles: Record = { categoryBtn: { border: 'none', padding: '6px 12px', - borderRadius: '6px', + borderRadius: '0px', fontSize: '12px', fontWeight: 500, cursor: 'pointer', diff --git a/frontend/src/features/terminal/components/TerminalModal.tsx b/frontend/src/features/terminal/components/TerminalModal.tsx index 411b9c3..c2421fa 100644 --- a/frontend/src/features/terminal/components/TerminalModal.tsx +++ b/frontend/src/features/terminal/components/TerminalModal.tsx @@ -102,12 +102,12 @@ export default function TerminalModal({ containerId, nodeName, onClose }: Termin
-
{activeTab === 'cheatsheet' && ( @@ -152,7 +152,9 @@ const styles: Record = { justifyContent: 'space-between', padding: '8px 16px 0 16px', borderBottom: '1px solid var(--border-color)', - backgroundColor: 'rgba(0, 0, 0, 0.1)', + backgroundColor: '#FFFFFF', + borderTopLeftRadius: '12px', + borderTopRightRadius: '12px', }, tabs: { display: 'flex', @@ -161,7 +163,8 @@ const styles: Record = { tabBtn: { background: 'none', border: 'none', - borderBottom: '2px solid transparent', + borderTopLeftRadius: '6px', + borderTopRightRadius: '6px', color: 'var(--color-text-secondary)', cursor: 'pointer', padding: '8px 16px', @@ -173,7 +176,7 @@ const styles: Record = { }, activeTabBtn: { color: '#3B82F6', - borderBottomColor: '#3B82F6', + backgroundColor: 'var(--bg-main)', fontWeight: 600, }, closeBtn: { diff --git a/frontend/src/index.css b/frontend/src/index.css index bebbe3c..27587a3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -32,6 +32,30 @@ width: 100vw; } +/* Global scrollbar styling */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.12); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.24); +} + body { margin: 0; padding: 0; @@ -89,6 +113,15 @@ body { } .xterm-viewport { border-radius: 8px; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.xterm-viewport::-webkit-scrollbar { + display: none; + width: 0px; + height: 0px; + background: transparent; } /* Rotation Spin animation */ From 4184fa800834cde87e8d84deabf98d44327305df Mon Sep 17 00:00:00 2001 From: Derssa Date: Wed, 17 Jun 2026 13:10:14 -0400 Subject: [PATCH 004/315] feat(backend): support postgres nodes and explorer/query execution api --- .../infrastructure/docker/ContainerManager.ts | 155 +++++++++++++++--- .../docker/DockerInitializer.ts | 73 +++++---- .../controllers/containerController.ts | 29 +++- .../containers/routes/containerRoutes.ts | 2 + .../containers/services/containerService.ts | 75 ++++++++- 5 files changed, 273 insertions(+), 61 deletions(-) diff --git a/backend/src/infrastructure/docker/ContainerManager.ts b/backend/src/infrastructure/docker/ContainerManager.ts index c5b42f3..46ca55e 100644 --- a/backend/src/infrastructure/docker/ContainerManager.ts +++ b/backend/src/infrastructure/docker/ContainerManager.ts @@ -6,15 +6,17 @@ export interface ContainerInfo { image: string; state: string; status: string; + type?: 'ubuntu' | 'postgres'; + port?: string; } export class ContainerManager { private static LAB_PREFIX = 'akal-lab-'; private static readonly UBUNTU_IMAGE_TAG = 'derssa/backend-lab-ubuntu:v1'; + private static readonly POSTGRES_IMAGE_TAG = 'postgres:15-alpine'; /** * Ensures that the custom prebuilt Ubuntu image exists locally. - * If not, pulls it from Docker Hub and logs progress layer-by-layer. */ private static async ensureUbuntuImage(): Promise { const images = await docker.listImages(); @@ -47,55 +49,162 @@ export class ContainerManager { } } + /** + * Ensures that the Postgres image exists locally. + */ + private static async ensurePostgresImage(): Promise { + const images = await docker.listImages(); + const hasImage = images.some(img => + img.RepoTags && img.RepoTags.includes(this.POSTGRES_IMAGE_TAG) + ); + + if (!hasImage) { + console.log('Pulling Postgres image (first time only)...'); + await new Promise((resolve, reject) => { + docker.pull(this.POSTGRES_IMAGE_TAG, {}, (err, stream) => { + if (err) return reject(err); + if (!stream) return reject(new Error('Pull stream is undefined')); + + docker.modem.followProgress( + stream, + (errFinished) => { + if (errFinished) return reject(errFinished); + resolve(); + }, + (event) => { + if (event.status) { + const progress = event.progress ? ` ${event.progress}` : ''; + console.log(`[Docker Hub Pull - Postgres] ${event.status}${progress}`); + } + } + ); + }); + }); + } + } + public static async listContainersByProject(projectId: string): Promise { const containers = await docker.listContainers({ all: true }); return containers .filter(c => c.Labels && c.Labels['akal.project.id'] === projectId) - .map(c => ({ - id: c.Id, - name: c.Names[0].replace(/^\//, '').replace(`${this.LAB_PREFIX}${projectId}-`, ''), - image: c.Image, - state: c.State, - status: c.Status - })); + .map(c => { + let port = ''; + if (c.Ports && c.Ports.length > 0) { + const matchedPort = c.Ports.find(p => p.PrivatePort === 5432); + if (matchedPort && matchedPort.PublicPort) { + port = matchedPort.PublicPort.toString(); + } + } + return { + id: c.Id, + name: c.Names[0].replace(/^\//, '').replace(`${this.LAB_PREFIX}${projectId}-`, ''), + image: c.Image, + state: c.State, + status: c.Status, + type: (c.Labels['akal.node.type'] || 'ubuntu') as 'ubuntu' | 'postgres', + port + }; + }); } - public static async createContainer(projectId: string, nodeName: string): Promise { - // Automatically ensure prebuilt image is pulled - await this.ensureUbuntuImage(); + public static async createContainer(projectId: string, nodeName: string, type: string = 'ubuntu'): Promise { + const isPostgres = type === 'postgres'; + const image = isPostgres ? this.POSTGRES_IMAGE_TAG : this.UBUNTU_IMAGE_TAG; + + if (isPostgres) { + await this.ensurePostgresImage(); + } else { + await this.ensureUbuntuImage(); + } - console.log('Creating container...'); + console.log(`Creating ${type} container...`); const safeName = `${this.LAB_PREFIX}${projectId}-${nodeName.replace(/[^a-zA-Z0-9-_]/g, '')}`; - const container = await docker.createContainer({ - Image: this.UBUNTU_IMAGE_TAG, + const createOpts: any = { + Image: image, name: safeName, - Cmd: ['/bin/bash'], - Tty: true, - OpenStdin: true, - StdinOnce: false, Labels: { - 'akal.project.id': projectId + 'akal.project.id': projectId, + 'akal.node.type': type }, HostConfig: { AutoRemove: false } - }); + }; + + if (isPostgres) { + createOpts.Env = ['POSTGRES_PASSWORD=postgres']; + createOpts.HostConfig.PortBindings = { + '5432/tcp': [{ HostPort: '' }] // Docker allocates dynamic host port + }; + } else { + createOpts.Cmd = ['/bin/bash']; + createOpts.Tty = true; + createOpts.OpenStdin = true; + createOpts.StdinOnce = false; + } + + const container = await docker.createContainer(createOpts); console.log('Starting container...'); await container.start(); - console.log('Ubuntu node ready'); + let port = ''; + if (isPostgres) { + const inspectData = await container.inspect(); + const ports = inspectData.NetworkSettings.Ports; + if (ports && ports['5432/tcp'] && ports['5432/tcp'][0]) { + port = ports['5432/tcp'][0].HostPort; + } + } + + console.log(`${type} node ready`); return { id: container.id, name: nodeName, - image: this.UBUNTU_IMAGE_TAG, + image, state: 'running', - status: 'Up less than a second' + status: 'Up less than a second', + type: type as 'ubuntu' | 'postgres', + port }; } + public static async executePsqlCommand(containerId: string, database: string, sqlQuery: string, extraArgs: string[] = []): Promise { + const container = docker.getContainer(containerId); + + const exec = await container.exec({ + Cmd: ['psql', '-U', 'postgres', '-d', database, ...extraArgs, '-c', sqlQuery], + AttachStdout: true, + AttachStderr: true + }); + + const stream = await exec.start({}); + + return new Promise((resolve, reject) => { + let output = ''; + + container.modem.demuxStream(stream, { + write: (chunk: Buffer) => { + output += chunk.toString(); + } + }, { + write: (chunk: Buffer) => { + output += chunk.toString(); + } + }); + + stream.on('end', () => { + resolve(output.trim()); + }); + + stream.on('error', (err) => { + reject(err); + }); + }); + } + public static async startContainer(id: string): Promise { const container = docker.getContainer(id); await container.start(); diff --git a/backend/src/infrastructure/docker/DockerInitializer.ts b/backend/src/infrastructure/docker/DockerInitializer.ts index 35cf9a0..b656958 100644 --- a/backend/src/infrastructure/docker/DockerInitializer.ts +++ b/backend/src/infrastructure/docker/DockerInitializer.ts @@ -2,6 +2,7 @@ import docker from './DockerClient'; export class DockerInitializer { private static readonly UBUNTU_IMAGE_TAG = 'derssa/backend-lab-ubuntu:v1'; + private static readonly POSTGRES_IMAGE_TAG = 'postgres:15-alpine'; private static isInitializing = false; /** @@ -11,7 +12,7 @@ export class DockerInitializer { if (this.isInitializing) return; this.isInitializing = true; - this.checkAndPullImage() + this.checkAndPullImages() .catch(err => { console.error('[DockerInitializer] Error during initialization:', err); }) @@ -20,45 +21,49 @@ export class DockerInitializer { }); } - private static async checkAndPullImage(): Promise { - console.log('Checking Ubuntu image...'); - + private static async checkAndPullImages(): Promise { try { const images = await docker.listImages(); - const hasImage = images.some(img => - img.RepoTags && img.RepoTags.includes(this.UBUNTU_IMAGE_TAG) - ); + const tags = images.flatMap(img => img.RepoTags || []); - if (hasImage) { - console.log('Ubuntu image ready'); - return; - } - - console.log('Pulling Ubuntu image (first run only)...'); - await new Promise((resolve, reject) => { - docker.pull(this.UBUNTU_IMAGE_TAG, {}, (err, stream) => { - if (err) return reject(err); - if (!stream) return reject(new Error('Pull stream is undefined')); - - docker.modem.followProgress( - stream, - (errFinished) => { - if (errFinished) return reject(errFinished); - console.log('Ubuntu image ready'); - resolve(); - }, - (event) => { - if (event.status) { - const progress = event.progress ? ` ${event.progress}` : ''; - console.log(`[Docker Hub Pull] ${event.status}${progress}`); - } - } - ); - }); - }); + await this.ensureImage(tags, this.UBUNTU_IMAGE_TAG, 'Ubuntu'); + await this.ensureImage(tags, this.POSTGRES_IMAGE_TAG, 'PostgreSQL'); } catch (err) { console.error('[DockerInitializer] Docker check failed. Is Docker running?'); throw err; } } + + private static async ensureImage(existingTags: string[], tag: string, label: string): Promise { + console.log(`Checking ${label} image...`); + const hasImage = existingTags.includes(tag); + + if (hasImage) { + console.log(`${label} image ready`); + return; + } + + console.log(`Pulling ${label} image (first run only)...`); + await new Promise((resolve, reject) => { + docker.pull(tag, {}, (err, stream) => { + if (err) return reject(err); + if (!stream) return reject(new Error('Pull stream is undefined')); + + docker.modem.followProgress( + stream, + (errFinished) => { + if (errFinished) return reject(errFinished); + console.log(`${label} image ready`); + resolve(); + }, + (event) => { + if (event.status) { + const progress = event.progress ? ` ${event.progress}` : ''; + console.log(`[Docker Hub Pull - ${label}] ${event.status}${progress}`); + } + } + ); + }); + }); + } } diff --git a/backend/src/modules/containers/controllers/containerController.ts b/backend/src/modules/containers/controllers/containerController.ts index fb97caa..3606e91 100644 --- a/backend/src/modules/containers/controllers/containerController.ts +++ b/backend/src/modules/containers/controllers/containerController.ts @@ -15,12 +15,12 @@ export class ContainerController { public static async create(req: Request, res: Response): Promise { try { const { projectId } = req.params; - const { name } = req.body; + const { name, type } = req.body; if (!name) { res.status(400).json({ error: 'Name is required' }); return; } - const container = await ContainerService.createContainer(projectId as string, name); + const container = await ContainerService.createContainer(projectId as string, name, type || 'ubuntu'); res.status(201).json(container); } catch (err: any) { res.status(500).json({ error: err.message }); @@ -53,4 +53,29 @@ export class ContainerController { res.status(500).json({ error: err.message }); } } + + public static async postgresExplorer(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const explorerData = await ContainerService.getPostgresExplorer(id as string); + res.json(explorerData); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + } + + public static async postgresQuery(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const { query, database } = req.body; + if (!query) { + res.status(400).json({ error: 'Query is required' }); + return; + } + const result = await ContainerService.executePostgresQuery(id as string, database || 'postgres', query); + res.json({ result }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + } } diff --git a/backend/src/modules/containers/routes/containerRoutes.ts b/backend/src/modules/containers/routes/containerRoutes.ts index 1745092..88271d8 100644 --- a/backend/src/modules/containers/routes/containerRoutes.ts +++ b/backend/src/modules/containers/routes/containerRoutes.ts @@ -8,5 +8,7 @@ router.post('/', ContainerController.create); router.post('/:id/start', ContainerController.start); router.post('/:id/stop', ContainerController.stop); router.delete('/:id', ContainerController.delete); +router.get('/:id/postgres/explorer', ContainerController.postgresExplorer); +router.post('/:id/postgres/query', ContainerController.postgresQuery); export default router; diff --git a/backend/src/modules/containers/services/containerService.ts b/backend/src/modules/containers/services/containerService.ts index 673cb88..780ca8a 100644 --- a/backend/src/modules/containers/services/containerService.ts +++ b/backend/src/modules/containers/services/containerService.ts @@ -5,8 +5,8 @@ export class ContainerService { return ContainerManager.listContainersByProject(projectId); } - public static async createContainer(projectId: string, name: string): Promise { - return ContainerManager.createContainer(projectId, name); + public static async createContainer(projectId: string, name: string, type?: string): Promise { + return ContainerManager.createContainer(projectId, name, type); } public static async startContainer(id: string): Promise { @@ -20,4 +20,75 @@ export class ContainerService { public static async deleteContainer(id: string): Promise { await ContainerManager.deleteContainer(id); } + + public static async getPostgresExplorer(containerId: string) { + // Get list of databases (filtering out templates) + const dbsRaw = await ContainerManager.executePsqlCommand( + containerId, + 'postgres', + "SELECT datname FROM pg_database WHERE datistemplate = false AND datname NOT IN ('template1');", + ['-t', '-A'] + ); + const databases = dbsRaw.split('\n').map(db => db.trim()).filter(Boolean); + + // Ensure 'postgres' is listed if not already present + if (!databases.includes('postgres')) { + databases.unshift('postgres'); + } + + const explorer: any[] = []; + + for (const db of databases) { + try { + // Get public tables in this database + const tablesRaw = await ContainerManager.executePsqlCommand( + containerId, + db, + "SELECT tablename FROM pg_tables WHERE schemaname = 'public';", + ['-t', '-A'] + ); + const tables = tablesRaw.split('\n').map(t => t.trim()).filter(Boolean); + + const tableNodes: any[] = []; + for (const table of tables) { + // Get columns and types in this table + const colsRaw = await ContainerManager.executePsqlCommand( + containerId, + db, + `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '${table}';`, + ['-t', '-A', '-F', ':'] + ); + const columns = colsRaw.split('\n').map(line => { + const parts = line.split(':'); + return { + name: parts[0]?.trim(), + type: parts[1]?.trim() + }; + }).filter(c => c.name); + + tableNodes.push({ + name: table, + columns + }); + } + + explorer.push({ + database: db, + tables: tableNodes + }); + } catch (err) { + explorer.push({ + database: db, + tables: [], + error: true + }); + } + } + + return explorer; + } + + public static async executePostgresQuery(containerId: string, database: string, query: string): Promise { + return ContainerManager.executePsqlCommand(containerId, database, query); + } } From 5e65664ca0d9aedb525d61419f6cfe0caa5d82ba Mon Sep 17 00:00:00 2001 From: Derssa Date: Wed, 17 Jun 2026 13:11:58 -0400 Subject: [PATCH 005/315] feat(frontend): integrate node library sidebar, drag and drop, postgres node & modal --- .../nodes/PostgresNode/PostgresModal.tsx | 687 ++++++++++++++++++ .../nodes/PostgresNode/PostgresNode.tsx | 94 +++ frontend/src/pages/CanvasPage/CanvasPage.tsx | 135 +++- .../CanvasPage/components/CanvasTopbar.tsx | 7 - .../CanvasPage/components/NodeLibrary.tsx | 217 ++++++ frontend/src/shared/hooks/useContainers.ts | 4 +- frontend/src/shared/types/index.ts | 2 + 7 files changed, 1115 insertions(+), 31 deletions(-) create mode 100644 frontend/src/features/nodes/PostgresNode/PostgresModal.tsx create mode 100644 frontend/src/features/nodes/PostgresNode/PostgresNode.tsx create mode 100644 frontend/src/pages/CanvasPage/components/NodeLibrary.tsx diff --git a/frontend/src/features/nodes/PostgresNode/PostgresModal.tsx b/frontend/src/features/nodes/PostgresNode/PostgresModal.tsx new file mode 100644 index 0000000..124edec --- /dev/null +++ b/frontend/src/features/nodes/PostgresNode/PostgresModal.tsx @@ -0,0 +1,687 @@ +import { useState, useEffect } from 'react'; +import { X, Database, Table, Columns, Play, Copy, Check, Search, BookOpen, Terminal, RefreshCw, AlertCircle } from 'lucide-react'; +import { API_BASE } from '../../../shared/types'; + +interface PostgresModalProps { + containerId: string; + nodeName: string; + projectId: string; + onClose: () => void; +} + +interface DBColumn { + name: string; + type: string; +} + +interface DBTable { + name: string; + columns: DBColumn[]; +} + +interface DBNode { + database: string; + tables: DBTable[]; + error?: boolean; +} + +const CHEAT_SHEET_DATA = [ + { + name: "psql Connection", + category: "Connection", + description: "Connect to database from command line.", + example: "psql -U postgres -d postgres" + }, + { + name: "Create Database", + category: "Database Commands", + description: "Create a new database.", + example: "CREATE DATABASE my_shop_db;" + }, + { + name: "List Databases", + category: "Database Commands", + description: "psql shortcut to list all databases.", + example: "\\l" + }, + { + name: "Switch Database", + category: "Database Commands", + description: "psql shortcut to switch to another database.", + example: "\\c my_shop_db" + }, + { + name: "Create Table", + category: "Table Commands", + description: "Create a table with columns and data types.", + example: "CREATE TABLE users (\n id SERIAL PRIMARY KEY,\n name VARCHAR(100) NOT NULL,\n email VARCHAR(100) UNIQUE,\n created_at TIMESTAMP DEFAULT NOW()\n);" + }, + { + name: "Insert Rows", + category: "Queries", + description: "Insert new data into a table.", + example: "INSERT INTO users (name, email) VALUES\n('Alice Smith', 'alice@example.com'),\n('Bob Jones', 'bob@example.com');" + }, + { + name: "Select Data", + category: "Queries", + description: "Query and filter records.", + example: "SELECT name, email FROM users WHERE name LIKE 'A%';" + }, + { + name: "Update Data", + category: "Queries", + description: "Modify existing table rows.", + example: "UPDATE users SET email = 'alice.new@example.com' WHERE id = 1;" + }, + { + name: "Delete Data", + category: "Queries", + description: "Delete rows from a table.", + example: "DELETE FROM users WHERE id = 2;" + }, + { + name: "Inner Join", + category: "Joins", + description: "Join records from two tables sharing matching keys.", + example: "SELECT orders.id, users.name\nFROM orders\nINNER JOIN users ON orders.user_id = users.id;" + }, + { + name: "Left Join", + category: "Joins", + description: "Get all records from the left table, plus matched records from the right.", + example: "SELECT users.name, orders.id\nFROM users\nLEFT JOIN orders ON users.id = orders.user_id;" + }, + { + name: "Create Index", + category: "Indexes", + description: "Speed up search query lookups on a column.", + example: "CREATE INDEX idx_users_email ON users(email);" + } +]; + +export default function PostgresModal({ containerId, nodeName, projectId, onClose }: PostgresModalProps) { + const [activeTab, setActiveTab] = useState<'explorer' | 'shell' | 'cheatsheet'>('explorer'); + const [explorerData, setExplorerData] = useState([]); + const [loadingExplorer, setLoadingExplorer] = useState(false); + + // Shell states + const [selectedDb, setSelectedDb] = useState('postgres'); + const [sqlQuery, setSqlQuery] = useState('SELECT * FROM pg_tables WHERE schemaname = \'public\';'); + const [queryOutput, setQueryOutput] = useState(''); + const [executing, setExecuting] = useState(false); + + // Cheat Sheet Search + const [cheatQuery, setCheatQuery] = useState(''); + const [copiedIndex, setCopiedIndex] = useState(null); + + // Database structure expand/collapse maps + const [expandedDBs, setExpandedDBs] = useState>({ postgres: true }); + const [expandedTables, setExpandedTables] = useState>({}); + + const fetchExplorerData = async () => { + try { + setLoadingExplorer(true); + const res = await fetch(`${API_BASE}/api/projects/${projectId}/containers/${containerId}/postgres/explorer`); + if (res.ok) { + const data = await res.json(); + setExplorerData(data); + // Default selectedDb to first datname if currently default postgres + if (data.length > 0 && selectedDb === 'postgres') { + setSelectedDb(data[0].database); + } + } + } catch (err) { + console.error(err); + } finally { + setLoadingExplorer(false); + } + }; + + useEffect(() => { + fetchExplorerData(); + }, [containerId]); + + const handleExecuteQuery = async () => { + try { + setExecuting(true); + setQueryOutput('Executing query in container...'); + const res = await fetch(`${API_BASE}/api/projects/${projectId}/containers/${containerId}/postgres/query`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: sqlQuery, database: selectedDb }) + }); + const data = await res.json(); + if (res.ok) { + setQueryOutput(data.result || 'Query executed successfully with no output.'); + // Refresh explorer in background to capture structural changes + fetchExplorerData(); + } else { + setQueryOutput(`ERROR: ${data.error}`); + } + } catch (err: any) { + setQueryOutput(`Execution failed: ${err.message}`); + } finally { + setExecuting(false); + } + }; + + const handleCopyCheat = (code: string, idx: number) => { + navigator.clipboard.writeText(code); + setCopiedIndex(idx); + setTimeout(() => setCopiedIndex(null), 2000); + }; + + const toggleDBExpand = (db: string) => { + setExpandedDBs(prev => ({ ...prev, [db]: !prev[db] })); + }; + + const toggleTableExpand = (tblKey: string) => { + setExpandedTables(prev => ({ ...prev, [tblKey]: !prev[tblKey] })); + }; + + // Filtered Cheatsheet + const filteredCheatSheet = CHEAT_SHEET_DATA.filter(item => { + const query = cheatQuery.toLowerCase(); + return ( + item.name.toLowerCase().includes(query) || + item.description.toLowerCase().includes(query) || + item.category.toLowerCase().includes(query) + ); + }); + + return ( +
+
+ {/* Header Tabs */} +
+
+ + + +
+ +
+ + {/* Modal Body */} +
+ {/* TAB 1: Database Explorer */} + {activeTab === 'explorer' && ( +
+
+ Inspect tables and schema: {nodeName} + +
+ +
+ {explorerData.map(node => ( +
+
toggleDBExpand(node.database)}> + + {node.database} + {node.error && } +
+ + {expandedDBs[node.database] && ( +
+ {node.tables.length > 0 ? ( + node.tables.map(table => { + const tblKey = `${node.database}:${table.name}`; + return ( +
+
toggleTableExpand(tblKey)}> + + {table.name} + + + {expandedTables[tblKey] && ( +
+ {table.columns.map(col => ( +
+ + {col.name} + {col.type} +
+ ))} +
+ )} + + ); + }) + ) : ( +
No public tables found.
+ )} + + )} + + ))} + + + )} + + {/* TAB 2: SQL Shell */} + {activeTab === 'shell' && ( +
+
+
+ Target Database: + +
+ +
+ +