diff --git a/app/package-lock.json b/app/package-lock.json index 772d2a4d..bfce3163 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,7 +8,7 @@ "name": "queryweaver-app", "version": "0.3.2", "dependencies": { - "@falkordb/canvas": "^0.0.45", + "@falkordb/canvas": "^0.2.7", "@hookform/resolvers": "^5.7.1", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", @@ -505,9 +505,9 @@ } }, "node_modules/@falkordb/canvas": { - "version": "0.0.45", - "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.0.45.tgz", - "integrity": "sha512-BIb24rfnaLCxO2UH7L4cNYLikmOTXlVR4I/FIpEG/d9ZEp3F+9U/ugaLYOcYe1pmZzT72YnzWUuvdhNE3zK99A==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.2.7.tgz", + "integrity": "sha512-AC13kQ/LDC4klPDKlczfiCIXnHx0Y94uw+S7xQAiD+7AKECw+bA1Xfl1dfIKRUXaNjIlmDzKApJ924OFHNwjtw==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -6256,9 +6256,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6276,9 +6273,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6296,9 +6290,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6316,9 +6307,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6336,9 +6324,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6356,9 +6341,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6557,9 +6539,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6577,9 +6556,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6597,9 +6573,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6617,9 +6590,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6637,9 +6607,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6657,9 +6624,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6870,9 +6834,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6890,9 +6851,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6910,9 +6868,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6930,9 +6885,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8612,7 +8564,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -9122,9 +9074,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9146,9 +9095,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9170,9 +9116,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9194,9 +9137,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/app/package.json b/app/package.json index 0bf51cd3..25dd6533 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@falkordb/canvas": "^0.0.45", + "@falkordb/canvas": "^0.2.7", "@hookform/resolvers": "^5.7.1", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", diff --git a/app/src/App.tsx b/app/src/App.tsx index 10ca2825..3ea101d4 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from "@/contexts/AuthContext"; import { DatabaseProvider } from "@/contexts/DatabaseContext"; import { SettingsProvider } from "@/contexts/SettingsContext"; import { ChatProvider } from "@/contexts/ChatContext"; +import { QueryHighlightProvider } from "@/contexts/QueryHighlightContext"; import Index from "./pages/Index"; import Settings from "./pages/Settings"; import NotFound from "./pages/NotFound"; @@ -18,17 +19,19 @@ const App = () => ( - - - - - } /> - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - - - + + + + + + } /> + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + + + + diff --git a/app/src/components/chat/ChatInterface.tsx b/app/src/components/chat/ChatInterface.tsx index 6bfd0718..3c9ed543 100644 --- a/app/src/components/chat/ChatInterface.tsx +++ b/app/src/components/chat/ChatInterface.tsx @@ -5,6 +5,7 @@ import { useDatabase } from "@/contexts/DatabaseContext"; import { useAuth } from "@/contexts/AuthContext"; import { useSettings } from "@/contexts/SettingsContext"; import { useChat } from "@/contexts/ChatContext"; +import { useQueryHighlight } from "@/contexts/QueryHighlightContext"; import LoadingSpinner from "@/components/ui/loading-spinner"; import { Skeleton } from "@/components/ui/skeleton"; import ChatMessage from "./ChatMessage"; @@ -58,6 +59,7 @@ const ChatInterface = ({ const { selectedGraph } = useDatabase(); const { vendor, apiKey, modelName, isApiKeyValid } = useSettings(); const { messages, setMessages, conversationHistory, isProcessing, setIsProcessing } = useChat(); + const { selectedQueryId, toggleQueryHighlight } = useQueryHighlight(); const messagesEndRef = useRef(null); const chatContainerRef = useRef(null); @@ -510,6 +512,8 @@ const ChatInterface = ({ analysisInfo={msg.analysisInfo} confirmationData={msg.confirmationData} user={user} + isQueryHighlighted={msg.type === 'sql-query' && selectedQueryId === msg.id} + onToggleQueryHighlight={msg.type === 'sql-query' ? () => toggleQueryHighlight(msg.id, msg.content) : undefined} onConfirm={msg.type === 'confirmation' ? () => handleConfirmDestructive(msg.id) : undefined} onCancel={msg.type === 'confirmation' ? () => handleCancelDestructive(msg.id) : undefined} /> diff --git a/app/src/components/chat/ChatMessage.tsx b/app/src/components/chat/ChatMessage.tsx index f8848d72..c23116e7 100644 --- a/app/src/components/chat/ChatMessage.tsx +++ b/app/src/components/chat/ChatMessage.tsx @@ -31,11 +31,13 @@ interface ChatMessageProps { }; progress?: number; // Progress percentage for AI steps user?: UserType | null; // User info for avatar + isQueryHighlighted?: boolean; // Whether this query's tables are highlighted in the schema canvas + onToggleQueryHighlight?: () => void; // Select/unselect this query to highlight it in the schema canvas onConfirm?: () => void; onCancel?: () => void; } -const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmationData, progress, user, onConfirm, onCancel }: ChatMessageProps) => { +const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmationData, progress, user, isQueryHighlighted, onToggleQueryHighlight, onConfirm, onCancel }: ChatMessageProps) => { const [copied, setCopied] = useState(false); const handleCopyQuery = async () => { @@ -48,6 +50,13 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati } }; + // Clicking the query toggles the schema highlight, but a click that ends a + // text selection must not steal the selection from the user. + const handleQueryBlockClick = () => { + if (window.getSelection()?.toString()) return; + onToggleQueryHighlight?.(); + }; + if (type === 'confirmation') { const operationType = (confirmationData?.operationType ?? 'UNKNOWN').toUpperCase(); const isHighRisk = ['DELETE', 'DROP', 'TRUNCATE'].includes(operationType); @@ -148,6 +157,7 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati if (type === 'sql-query') { const hasSQL = content && content.trim().length > 0; const isValid = analysisInfo?.isValid !== false; // Default to true if not specified + const isClickable = hasSQL && Boolean(onToggleQueryHighlight); return (
@@ -165,6 +175,23 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati {hasSQL ? 'Generated SQL Query' : 'Query Analysis'} + {isQueryHighlighted && ( + + Shown in schema + + )} + {isClickable && ( + + )}
{hasSQL && ( @@ -183,9 +210,22 @@ const ChatMessage = ({ type, content, steps, queryData, analysisInfo, confirmati )} -
+                    
                       {content}
                     
+ {isClickable && ( +

+ {isQueryHighlighted + ? 'Click the query again to clear the schema highlight.' + : 'Click the query to highlight its tables and relations in the schema.'} +

+ )} )} diff --git a/app/src/components/schema/SchemaCanvasControls.tsx b/app/src/components/schema/SchemaCanvasControls.tsx new file mode 100644 index 00000000..3ff47d9c --- /dev/null +++ b/app/src/components/schema/SchemaCanvasControls.tsx @@ -0,0 +1,472 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { KeyboardEvent, RefObject } from 'react'; +import type { + FalkorDBCanvas, + GraphNode, + HierarchyDirection, + LayoutMode, + RadialDirection, +} from '@falkordb/canvas'; +import { + ChevronDown, + Circle, + Pause, + Pin, + PinOff, + Play, + Search, + Shrink, + Telescope, + X, + ZoomIn, + ZoomOut, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +const LAYOUTS: { value: LayoutMode; label: string }[] = [ + { value: 'force', label: 'Force' }, + { value: 'tree', label: 'Tree' }, + { value: 'radial', label: 'Radial' }, +]; + +const HIERARCHY_DIRECTIONS: { value: HierarchyDirection; label: string }[] = [ + { value: 'td', label: 'Top → Down' }, + { value: 'bu', label: 'Bottom → Up' }, + { value: 'lr', label: 'Left → Right' }, + { value: 'rl', label: 'Right → Left' }, +]; + +const RADIAL_DIRECTIONS: { value: RadialDirection; label: string }[] = [ + { value: 'out', label: 'Outward' }, + { value: 'in', label: 'Inward' }, +]; + +const MAX_SUGGESTIONS = 8; + +const getDefaultDirection = (mode: LayoutMode) => { + if (mode === 'tree') return 'td'; + if (mode === 'radial') return 'out'; + return ''; +}; + +export interface SchemaTableOption { + id: number; + name: string; + columns: string[]; +} + +interface SchemaCanvasControlsProps { + canvasRef: RefObject; + tables: SchemaTableOption[]; + disabled?: boolean; + focusMode: boolean; + onFocusModeChange: (enabled: boolean) => void; + selectedTableId: number | null; + onSelectTable: (tableId: number | null) => void; +} + +const SchemaCanvasControls = ({ + canvasRef, + tables, + disabled = false, + focusMode, + onFocusModeChange, + selectedTableId, + onSelectTable, +}: SchemaCanvasControlsProps) => { + const [layout, setLayout] = useState('force'); + const [direction, setDirection] = useState(''); + const [animation, setAnimation] = useState(true); + const [pinned, setPinned] = useState(false); + const [search, setSearch] = useState(''); + const [suggestionsOpen, setSuggestionsOpen] = useState(false); + const [activeSuggestion, setActiveSuggestion] = useState(0); + + // Remembers the last direction picked per layout so switching back restores it. + const directionsRef = useRef>({ tree: 'td', radial: 'out' }); + + // A new schema invalidates the search term and its suggestions. + useEffect(() => { + setSearch(''); + setSuggestionsOpen(false); + setActiveSuggestion(0); + }, [tables]); + + const suggestions = useMemo(() => { + const term = search.trim().toLowerCase(); + + if (!term) return []; + + return tables + .filter( + (table) => + table.name.toLowerCase().includes(term) || + table.columns.some((column) => column.toLowerCase().includes(term)) + ) + .slice(0, MAX_SUGGESTIONS); + }, [tables, search]); + + const focusTable = useCallback( + (table: SchemaTableOption) => { + onSelectTable(table.id); + setSearch(table.name); + setSuggestionsOpen(false); + canvasRef.current?.zoomToFit(4, (node: GraphNode) => node.id === table.id); + }, + [canvasRef, onSelectTable] + ); + + const clearSearch = useCallback(() => { + setSearch(''); + setSuggestionsOpen(false); + onSelectTable(null); + }, [onSelectTable]); + + const handleSearchKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setSuggestionsOpen(false); + return; + } + + if (suggestions.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSuggestionsOpen(true); + setActiveSuggestion((index) => (index + 1) % suggestions.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSuggestionsOpen(true); + setActiveSuggestion((index) => (index - 1 + suggestions.length) % suggestions.length); + } else if (e.key === 'Enter') { + e.preventDefault(); + focusTable(suggestions[Math.min(activeSuggestion, suggestions.length - 1)]); + } + }; + + const handleZoom = (factor: number) => { + const canvas = canvasRef.current; + + if (!canvas) return; + + if (selectedTableId !== null) { + // Canvas node ids may be normalised to strings, so compare loosely. + const node = canvas + .getGraphData() + ?.nodes.find((n) => String(n.id) === String(selectedTableId)); + + if (node) { + canvas.centerAt(node.x ?? 0, node.y ?? 0, 300); + } + } + + canvas.zoom(canvas.getZoom() * factor); + }; + + const handleCenter = () => { + canvasRef.current?.zoomToFit(); + }; + + const applyDirection = (mode: LayoutMode, value: string) => { + if (mode === 'tree') { + canvasRef.current?.setLayoutOptions({ tree: { direction: value as HierarchyDirection } }); + } else if (mode === 'radial') { + canvasRef.current?.setLayoutOptions({ radial: { direction: value as RadialDirection } }); + } + }; + + const handleLayoutChange = (value: string, directionOverride?: string) => { + const mode = value as LayoutMode; + const dir = + mode === 'force' + ? '' + : (directionOverride ?? (directionsRef.current[mode] || getDefaultDirection(mode))); + + if (mode !== 'force') { + directionsRef.current = { ...directionsRef.current, [mode]: dir }; + } + + // Direction options must be applied before setLayout so the layout engine uses them. + applyDirection(mode, dir); + canvasRef.current?.setLayout(mode); + + setLayout(mode); + setDirection(dir); + // Tree and radial layouts are deterministic, so the canvas pins their nodes. + const nextPinned = mode !== 'force'; + setPinned(nextPinned); + canvasRef.current?.setPinOnDragEnd(nextPinned); + + if (nextPinned) { + setAnimation(false); + canvasRef.current?.setAnimation(false); + } + }; + + const handleDirectionChange = (value: string, targetLayout: LayoutMode) => { + directionsRef.current = { ...directionsRef.current, [targetLayout]: value }; + setDirection(value); + applyDirection(targetLayout, value); + }; + + const handleAnimationToggle = (checked: boolean) => { + setAnimation(checked); + canvasRef.current?.setAnimation(checked); + }; + + const handleFocusToggle = (checked: boolean) => { + onFocusModeChange(checked); + }; + + const handlePinToggle = () => { + const next = !pinned; + setPinned(next); + canvasRef.current?.setPinOnDragEnd(next); + + if (next) { + setAnimation(false); + canvasRef.current?.setAnimation(false); + } + }; + + const animationDisabled = disabled || pinned || layout !== 'force'; + + return ( +
+ {/* Search */} +
+ + { + setSearch(e.target.value); + setSuggestionsOpen(true); + setActiveSuggestion(0); + }} + onFocus={() => setSuggestionsOpen(true)} + onBlur={() => window.setTimeout(() => setSuggestionsOpen(false), 120)} + onKeyDown={handleSearchKeyDown} + /> + {search && ( + + )} + {suggestionsOpen && suggestions.length > 0 && ( +
    + {suggestions.map((table, index) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Layout */} + + + + + + + Force + + + + {layout === 'tree' && ( + + + + )} + Tree + + + {HIERARCHY_DIRECTIONS.map((d) => ( + { + // Switching layout already applies the picked direction first. + if (layout !== 'tree') handleLayoutChange('tree', d.value); + else handleDirectionChange(d.value, 'tree'); + }} + > + {layout === 'tree' && direction === d.value && ( + + + + )} + {d.label} + + ))} + + + + + {layout === 'radial' && ( + + + + )} + Radial + + + {RADIAL_DIRECTIONS.map((d) => ( + { + // Switching layout already applies the picked direction first. + if (layout !== 'radial') handleLayoutChange('radial', d.value); + else handleDirectionChange(d.value, 'radial'); + }} + > + {layout === 'radial' && direction === d.value && ( + + + + )} + {d.label} + + ))} + + + + + + {/* Animation */} +
+ {animation ? ( + + ) : ( + + )} + +
+ + {/* Focus mode */} +
+ + +
+ + {/* Pin / zoom */} +
+ + + + +
+
+ ); +}; + +export default SchemaCanvasControls; diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx index 47714714..1bc1715b 100644 --- a/app/src/components/schema/SchemaViewer.tsx +++ b/app/src/components/schema/SchemaViewer.tsx @@ -1,10 +1,12 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import type { Data, FalkorDBCanvas, GraphNode } from '@falkordb/canvas'; -import { ZoomIn, ZoomOut, Locate, X, GripVertical } from 'lucide-react'; +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import type { Data, FalkorDBCanvas, GraphNode, GraphLink } from '@falkordb/canvas'; +import { X, GripVertical } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useDatabase } from '@/contexts/DatabaseContext'; +import { useQueryHighlight } from '@/contexts/QueryHighlightContext'; import { DatabaseService } from '@/services/database'; import { useToast } from '@/components/ui/use-toast'; +import SchemaCanvasControls, { type SchemaTableOption } from './SchemaCanvasControls'; interface SchemaNode { id: number; @@ -31,14 +33,125 @@ interface SchemaViewerProps { sidebarWidth?: number; } +/** Accent used for tables/relations referenced by the selected SQL query. */ +const HIGHLIGHT_COLOR = '#8b5cf6'; +/** Opacity applied to schema elements the selected query does not touch. */ +const DIMMED_OPACITY = 0.25; + +/** Link endpoints are ids before the layout runs and node objects afterwards. */ +const endpointId = (endpoint: unknown): string => { + if (endpoint && typeof endpoint === 'object' && 'id' in endpoint) { + return String((endpoint as { id: unknown }).id); + } + return String(endpoint); +}; + +/** Direction-agnostic key so a relation matches however the canvas orders it. */ +const linkKey = (source: unknown, target: unknown): string => + [endpointId(source), endpointId(target)].sort().join('|'); + +// Must stay above the canvas' `interaction.zoomToFitDelay` (50ms default) so the +// highlight framing is applied after the canvas' own initial fit. +const HIGHLIGHT_ZOOM_DELAY_MS = 100; + const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: SchemaViewerProps) => { const canvasRef = useRef(null); const resizeRef = useRef(null); + // Schema snapshot currently seeded into the canvas, used to avoid re-seeding + // (and losing node positions) when only the highlight changed. + const renderedSchemaRef = useRef(null); const [schemaData, setSchemaData] = useState(null); const [loading, setLoading] = useState(false); + // Focus mode dims everything that is not connected to the hovered/selected table. + const [focusMode, setFocusMode] = useState(false); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [selectedTableId, setSelectedTableId] = useState(null); const { selectedGraph } = useDatabase(); + const { highlightedTables, clearQueryHighlight } = useQueryHighlight(); const { toast } = useToast(); + // Tables referenced by the currently selected SQL query, and the relations + // between them. Empty sets mean "no highlight" — everything renders normally. + const { highlightedNodeIds, highlightedLinkKeys } = useMemo(() => { + const nodeIds = new Set(); + const linkKeys = new Set(); + + if (!schemaData || highlightedTables.length === 0) { + return { highlightedNodeIds: nodeIds, highlightedLinkKeys: linkKeys }; + } + + const wanted = new Set(highlightedTables.map((table) => table.toLowerCase())); + schemaData.nodes.forEach((node) => { + if (node.name && wanted.has(String(node.name).toLowerCase())) { + nodeIds.add(node.id); + } + }); + + schemaData.links.forEach((link) => { + if (nodeIds.has(link.source) && nodeIds.has(link.target)) { + linkKeys.add(linkKey(link.source, link.target)); + } + }); + + return { highlightedNodeIds: nodeIds, highlightedLinkKeys: linkKeys }; + }, [schemaData, highlightedTables]); + + const hasHighlight = highlightedNodeIds.size > 0; + + // Table the user is pointing at (hover wins over the searched/clicked table). + const focusTargetId = hoveredNodeId ?? selectedTableId; + + // Elements rendered with the accent colour. A selected SQL query wins; otherwise + // the hovered/selected table together with its direct relations is emphasised. + const { emphasisNodeIds, emphasisLinkKeys } = useMemo(() => { + if (hasHighlight) { + return { emphasisNodeIds: highlightedNodeIds, emphasisLinkKeys: highlightedLinkKeys }; + } + + const nodeIds = new Set(); + const linkKeys = new Set(); + + if (schemaData && focusTargetId !== null) { + nodeIds.add(focusTargetId); + schemaData.links.forEach((link) => { + if (link.source === focusTargetId || link.target === focusTargetId) { + nodeIds.add(link.source); + nodeIds.add(link.target); + linkKeys.add(linkKey(link.source, link.target)); + } + }); + } + + return { emphasisNodeIds: nodeIds, emphasisLinkKeys: linkKeys }; + }, [hasHighlight, highlightedNodeIds, highlightedLinkKeys, schemaData, focusTargetId]); + + // A query highlight always dims the rest; hover only dims in focus mode. + const dimInactive = hasHighlight || (focusMode && emphasisNodeIds.size > 0); + + // Hover/selection emphasis is hidden while a query highlight is active, so + // drop it when the highlight clears instead of letting it reappear. + const hadHighlightRef = useRef(false); + useEffect(() => { + if (hadHighlightRef.current && !hasHighlight) { + setHoveredNodeId(null); + setSelectedTableId(null); + } + hadHighlightRef.current = hasHighlight; + }, [hasHighlight]); + + // Search options for the canvas controls. + const tableOptions = useMemo(() => { + if (!schemaData) return []; + + return schemaData.nodes.map((node) => ({ + id: node.id, + name: String(node.name ?? ''), + columns: (node.columns || []).map((column) => + typeof column === 'object' ? String(column.name ?? '') : String(column) + ), + })); + }, [schemaData]); + // Track current theme for canvas colors const [theme, setTheme] = useState(() => { return document.documentElement.getAttribute('data-theme') || 'dark'; @@ -156,6 +269,8 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch const nodesMap = new Map(data.nodes.map((node) => [node.id, node])); + setHoveredNodeId(null); + setSelectedTableId(null); setSchemaData({ ...data, nodesMap }); } catch (error) { console.error('Failed to load schema:', error); @@ -170,31 +285,10 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch } }; - const handleZoomIn = () => { - const canvas = canvasRef.current - - if (canvas) { - canvas.zoom(canvas.getZoom() * 1.1); - } - }; - - const handleZoomOut = () => { - const canvas = canvasRef.current; - - if (canvas) { - canvas.zoom(canvas.getZoom() * 0.9); - } - }; - - const handleCenter = useCallback(() => { - console.log("Stop"); - - const canvas = canvasRef.current; - - if (canvas) { - canvas.zoomToFit(); - } - }, []); + const linkColorFor = useCallback((sourceId: number, targetId: number) => { + if (hasHighlight && highlightedLinkKeys.has(linkKey(sourceId, targetId))) return HIGHLIGHT_COLOR; + return theme === 'light' ? '#9ca3af' : '#4b5563'; + }, [theme, hasHighlight, highlightedLinkKeys]); // Convert schema data to canvas format const convertToCanvasData = useCallback((data: SchemaData): Data => { @@ -226,7 +320,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch return { id: index + 1, relationship: 'REFERENCES', - color: theme === 'light' ? '#9ca3af' : '#4b5563', + color: linkColorFor(link.source, link.target), visible: true, source: link.source, target: link.target, @@ -235,9 +329,11 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch }); return { nodes, links }; - }, [theme]); + }, [theme, linkColorFor]); - // Set up canvas configuration and data - MUST be in single effect to ensure proper order + // Canvas configuration: custom table rendering, focus/dim predicates and + // interaction handlers. Kept separate from the data effect so hovering never + // re-seeds the graph. useEffect(() => { const canvas = canvasRef.current; @@ -262,13 +358,22 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch if (!schemaNode) return; + const isEmphasized = emphasisNodeIds.has(node.id); + const isDimmed = dimInactive && !isEmphasized; + const isActive = node.id === hoveredNodeId || node.id === selectedTableId; + const columns = schemaNode.columns || []; const nodeHeight = headerHeight + columns.length * lineHeight + padding * 2; + const previousAlpha = ctx.globalAlpha; + if (isDimmed) { + ctx.globalAlpha = previousAlpha * DIMMED_OPACITY; + } + ctx.fillStyle = fillColor; - ctx.strokeStyle = strokeColor; - ctx.lineWidth = 1; + ctx.strokeStyle = isEmphasized || isActive ? HIGHLIGHT_COLOR : strokeColor; + ctx.lineWidth = isActive ? 3 : isEmphasized ? 2.5 : 1; ctx.fillRect( (node.x || 0) - NODE_WIDTH / 2, (node.y || 0) - nodeHeight / 2, @@ -282,7 +387,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch nodeHeight ); - ctx.fillStyle = textColor; + ctx.fillStyle = isEmphasized || isActive ? HIGHLIGHT_COLOR : textColor; ctx.font = `bold ${fontSize}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; @@ -333,6 +438,8 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch colY += lineHeight; }); + + ctx.globalAlpha = previousAlpha; }; const nodePointerAreaPaint = (node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => { @@ -357,20 +464,74 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch }; - const canvasData = convertToCanvasData(schemaData); + const linkKeyOf = (link: GraphLink) => linkKey(link.source, link.target); canvas.setConfig({ - autoStopOnSettle: false, + dimmed: dimInactive, + dimOpacity: DIMMED_OPACITY, + // Nodes are drawn by `nodeCanvasObject`, which replaces the canvas' own + // node renderer — so node dimming is applied manually there and this + // predicate only keeps the config coherent. Links still dim natively. + isNodeDimmed: (node: GraphNode) => !emphasisNodeIds.has(node.id), + isLinkDimmed: (link: GraphLink) => !emphasisLinkKeys.has(linkKeyOf(link)), + isNodeSelected: (node: GraphNode) => + node.id === hoveredNodeId || node.id === selectedTableId, + isLinkSelected: (link: GraphLink) => emphasisLinkKeys.has(linkKeyOf(link)), node: { nodeCanvasObject, nodePointerAreaPaint, - } + }, + eventHandlers: { + onNodeHover: (node: GraphNode | null) => setHoveredNodeId(node ? node.id : null), + onNodeClick: (node: GraphNode) => + setSelectedTableId((current) => (current === node.id ? null : node.id)), + onBackgroundClick: () => setSelectedTableId(null), + }, }); - + canvas.setBackgroundColor(theme === 'light' ? '#ffffff' : '#191919'); canvas.setForegroundColor(theme === 'light' ? '#111' : '#f5f5f5'); - canvas.setData(canvasData); - }, [schemaData, theme, canvasLoaded, convertToCanvasData]); + }, [ + schemaData, + theme, + canvasLoaded, + dimInactive, + emphasisNodeIds, + emphasisLinkKeys, + hoveredNodeId, + selectedTableId, + ]); + + // Seed the canvas with the schema. `setData` recomputes the layout, so it only + // runs for a new schema; later updates reuse the existing node positions. + useEffect(() => { + const canvas = canvasRef.current; + + if (!canvas || !canvasLoaded || !schemaData) return; + + const canvasData = convertToCanvasData(schemaData); + + if (renderedSchemaRef.current !== schemaData) { + renderedSchemaRef.current = schemaData; + canvas.setData(canvasData); + return; + } + + canvas.setGraphData(canvasData); + }, [schemaData, canvasLoaded, convertToCanvasData]); + + // Bring the highlighted tables into view when a query is selected + useEffect(() => { + const canvas = canvasRef.current; + + if (!canvas || !canvasLoaded || !hasHighlight) return; + + const timer = setTimeout(() => { + canvas.zoomToFit(1.5, (node: GraphNode) => highlightedNodeIds.has(node.id)); + }, HIGHLIGHT_ZOOM_DELAY_MS); + + return () => clearTimeout(timer); + }, [canvasLoaded, hasHighlight, highlightedNodeIds]); if (!isOpen) return null; @@ -411,38 +572,38 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch {/* Controls */} -
- - - -
+ + Highlighting {highlightedNodeIds.size} table{highlightedNodeIds.size === 1 ? '' : 's'} used by the selected query + + + + )} {/* Graph Container */} -
+
{loading && (
Loading schema...
diff --git a/app/src/contexts/QueryHighlightContext.tsx b/app/src/contexts/QueryHighlightContext.tsx new file mode 100644 index 00000000..ded5716e --- /dev/null +++ b/app/src/contexts/QueryHighlightContext.tsx @@ -0,0 +1,60 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useDatabase } from '@/contexts/DatabaseContext'; +import { extractTablesFromSQL } from '@/utils/sqlTables'; + +interface QueryHighlightContextType { + /** Id of the chat message whose SQL query is currently selected. */ + selectedQueryId: string | null; + /** Table names referenced by the selected query (empty when nothing is selected). */ + highlightedTables: string[]; + /** Selects the query, or unselects it when it is already selected. */ + toggleQueryHighlight: (messageId: string, sql: string) => void; + clearQueryHighlight: () => void; +} + +const QueryHighlightContext = createContext(undefined); + +export const QueryHighlightProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { selectedGraph } = useDatabase(); + const [selectedQueryId, setSelectedQueryId] = useState(null); + const [highlightedTables, setHighlightedTables] = useState([]); + + const clearQueryHighlight = useCallback(() => { + setSelectedQueryId(null); + setHighlightedTables([]); + }, []); + + // The highlight refers to tables of the active schema — drop it on switch. + useEffect(() => { + clearQueryHighlight(); + }, [selectedGraph?.id, clearQueryHighlight]); + + const toggleQueryHighlight = useCallback((messageId: string, sql: string) => { + if (selectedQueryId === messageId) { + clearQueryHighlight(); + return; + } + + setSelectedQueryId(messageId); + setHighlightedTables(extractTablesFromSQL(sql)); + }, [selectedQueryId, clearQueryHighlight]); + + const value = useMemo( + () => ({ selectedQueryId, highlightedTables, toggleQueryHighlight, clearQueryHighlight }), + [selectedQueryId, highlightedTables, toggleQueryHighlight, clearQueryHighlight], + ); + + return ( + + {children} + + ); +}; + +export const useQueryHighlight = () => { + const context = useContext(QueryHighlightContext); + if (context === undefined) { + throw new Error('useQueryHighlight must be used within a QueryHighlightProvider'); + } + return context; +}; diff --git a/app/src/pages/Index.tsx b/app/src/pages/Index.tsx index f19059fb..7b64bd7b 100644 --- a/app/src/pages/Index.tsx +++ b/app/src/pages/Index.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; @@ -13,6 +13,7 @@ import SchemaViewer from "@/components/schema"; import LoadingSpinner from "@/components/ui/loading-spinner"; import { useAuth } from "@/contexts/AuthContext"; import { useDatabase } from "@/contexts/DatabaseContext"; +import { useQueryHighlight } from "@/contexts/QueryHighlightContext"; import { DatabaseService } from "@/services/database"; import { useToast } from "@/components/ui/use-toast"; import { csrfHeaders } from "@/lib/csrf"; @@ -27,6 +28,7 @@ import { const Index = () => { const { isAuthenticated, isLoading: authLoading, logout, user } = useAuth(); const { selectedGraph, graphs, selectGraph, uploadSchema } = useDatabase(); + const { selectedQueryId, clearQueryHighlight } = useQueryHighlight(); const { toast } = useToast(); const [showDatabaseModal, setShowDatabaseModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); @@ -131,6 +133,21 @@ const Index = () => { // No need to fetch rules - we just pass the toggle state to backend + // Reveal the schema panel when a SQL query is selected in the chat, but only + // when a database is connected — otherwise the panel has nothing to render. + useEffect(() => { + if (selectedQueryId && selectedGraph) { + setShowSchemaViewer(true); + } + }, [selectedQueryId, selectedGraph]); + + // Closing the panel also drops the highlight, so the chat toggle does not stay + // marked as active while nothing is highlighted. + const closeSchemaViewer = useCallback(() => { + setShowSchemaViewer(false); + clearQueryHighlight(); + }, [clearQueryHighlight]); + // Show login modal when not authenticated after loading completes useEffect(() => { // Only auto-open the login modal once per user/session to avoid locking @@ -344,7 +361,11 @@ const Index = () => { {/* Left Sidebar */} { if (!isRefreshingSchema) setShowSchemaViewer(!showSchemaViewer); }} + onSchemaClick={() => { + if (isRefreshingSchema) return; + if (showSchemaViewer) closeSchemaViewer(); + else setShowSchemaViewer(true); + }} isSchemaOpen={showSchemaViewer} isCollapsed={sidebarCollapsed} onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)} @@ -353,7 +374,7 @@ const Index = () => { {/* Schema Viewer */} setShowSchemaViewer(false)} + onClose={closeSchemaViewer} onWidthChange={setSchemaViewerWidth} sidebarWidth={sidebarWidth} /> diff --git a/app/src/utils/sqlTables.ts b/app/src/utils/sqlTables.ts new file mode 100644 index 00000000..9cb140ae --- /dev/null +++ b/app/src/utils/sqlTables.ts @@ -0,0 +1,139 @@ +/** + * Lightweight SQL table extraction. + * + * Pulls the table names referenced by a generated SQL query so the schema + * canvas can highlight the tables (and the relations between them) the query + * actually touches. This is intentionally a heuristic scanner — it does not + * aim to be a full SQL parser. + */ + +// Keywords that may directly follow FROM/JOIN/... and must never be treated +// as a table name or as a table alias. +const RESERVED = new Set([ + 'select', 'from', 'where', 'group', 'order', 'having', 'limit', 'offset', + 'union', 'intersect', 'except', 'join', 'inner', 'left', 'right', 'full', + 'cross', 'outer', 'natural', 'lateral', 'on', 'using', 'as', 'and', 'or', + 'not', 'set', 'values', 'returning', 'with', 'distinct', 'into', 'window', + 'fetch', 'for', 'exists', 'case', 'when', 'then', 'else', 'end', +]); + +// Optionally-qualified identifier: schema."table", `db`.`table`, [dbo].[table] +const IDENTIFIER = + '(?:[A-Za-z_][\\w$]*|"[^"]*"|`[^`]*`|\\[[^\\]]*\\])' + + '(?:\\s*\\.\\s*(?:[A-Za-z_][\\w$]*|"[^"]*"|`[^`]*`|\\[[^\\]]*\\]))*'; + +const unquote = (identifier: string): string => + identifier.replace(/^["`[]/, '').replace(/["`\]]$/, ''); + +// A single (possibly quoted) part of a qualified name. Quoted forms come first +// so that a dot inside quotes stays part of the same name. +const NAME_PART = /"[^"]*"|`[^`]*`|\[[^\]]*\]|[A-Za-z_][\w$]*/g; + +/** Keep only the table part of a qualified name (`public.users` → `users`). */ +const tableName = (qualified: string): string => { + const parts = qualified.match(NAME_PART) ?? []; + return unquote(parts[parts.length - 1] ?? ''); +}; + +// Functions that use FROM as an argument separator rather than a table clause. +const FROM_ARG_FUNCTIONS = new Set(['extract', 'substring', 'trim', 'overlay']); + +/** + * Blanks the body of quoted identifiers, keeping offsets intact, so a scan can + * ignore punctuation inside them (`TRIM(BOTH "x)" FROM name)`). + */ +const maskQuoted = (sql: string): string => + sql.replace(/"[^"]*"|`[^`]*`|\[[^\]]*\]/g, (span) => ' '.repeat(span.length)); + +/** + * True when the `FROM` at `index` belongs to a call such as + * `EXTRACT(YEAR FROM ts)` or `TRIM(BOTH ' ' FROM name)`, where what follows is + * a column rather than a table. `sql` must be quote-masked. + */ +const isFunctionArgumentFrom = (sql: string, index: number): boolean => { + let depth = 0; + + // Walk back to the innermost unclosed `(` and look at the name before it. + for (let i = index - 1; i >= 0; i -= 1) { + const char = sql[i]; + if (char === ')') { + depth += 1; + } else if (char === '(') { + if (depth === 0) { + const callee = /([A-Za-z_][\w$]*)\s*$/.exec(sql.slice(0, i)); + return !!callee && FROM_ARG_FUNCTIONS.has(callee[1].toLowerCase()); + } + depth -= 1; + } + } + + return false; +}; + +/** + * Extracts the table names referenced by a SQL statement. + * Returns unique names in the order they appear. + */ +export const extractTablesFromSQL = (sql: string): string[] => { + if (!sql || !sql.trim()) return []; + + // Drop comments and string literals so they cannot produce false matches. + const cleaned = sql + .replace(/--[^\n]*/g, ' ') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/'(?:''|[^'])*'/g, "''"); + + // Offset-preserving copy used for the keyword and parenthesis scans, so a + // quoted identifier such as `"from users"` cannot look like a clause. The + // extraction below still reads `cleaned` so quoted names stay intact. + const masked = maskQuoted(cleaned); + + const tables: string[] = []; + const seen = new Set(); + + // CTE names are deliberately kept: a CTE may shadow a real table + // (`WITH orders AS (SELECT * FROM public.orders) SELECT * FROM orders`), and + // callers match the result against the schema, so a name that is not a table + // simply never matches. + const add = (qualified: string) => { + const name = tableName(qualified); + const key = name.toLowerCase(); + if (!name || RESERVED.has(key) || seen.has(key)) return; + seen.add(key); + tables.push(name); + }; + + const keywordRe = /\b(from|join|update|into)\b/gi; + const referenceRe = new RegExp(`^\\s*(${IDENTIFIER})`, 'i'); + const aliasRe = /^\s+(?:as\s+)?([A-Za-z_][\w$]*)/i; + + let keyword: RegExpExecArray | null; + while ((keyword = keywordRe.exec(masked)) !== null) { + // Only `FROM` accepts a comma-separated list of tables. + const acceptsList = keyword[1].toLowerCase() === 'from'; + if (acceptsList && isFunctionArgumentFrom(masked, keyword.index)) continue; + let index = keywordRe.lastIndex; + + for (;;) { + const reference = cleaned.slice(index).match(referenceRe); + // A subquery or unexpected token — the inner FROM is picked up separately. + if (!reference) break; + + index += reference[0].length; + add(reference[1]); + + if (!acceptsList) break; + + const alias = cleaned.slice(index).match(aliasRe); + if (alias && !RESERVED.has(alias[1].toLowerCase())) { + index += alias[0].length; + } + + const comma = cleaned.slice(index).match(/^\s*,/); + if (!comma) break; + index += comma[0].length; + } + } + + return tables; +}; diff --git a/package-lock.json b/package-lock.json index b6b670c0..f668ace9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "name": "queryweaver-app", "version": "0.3.2", "dependencies": { - "@falkordb/canvas": "^0.0.45", + "@falkordb/canvas": "^0.2.7", "@hookform/resolvers": "^5.7.1", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", @@ -839,24 +839,6 @@ "undici-types": "~8.3.0" } }, - "app/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "app/node_modules/autoprefixer": { "version": "10.4.27", "dev": true, @@ -1135,14 +1117,6 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, - "app/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "optional": true, - "peer": true - }, "app/node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -1686,9 +1660,9 @@ } }, "node_modules/@falkordb/canvas": { - "version": "0.0.45", - "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.0.45.tgz", - "integrity": "sha512-BIb24rfnaLCxO2UH7L4cNYLikmOTXlVR4I/FIpEG/d9ZEp3F+9U/ugaLYOcYe1pmZzT72YnzWUuvdhNE3zK99A==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.2.7.tgz", + "integrity": "sha512-AC13kQ/LDC4klPDKlczfiCIXnHx0Y94uw+S7xQAiD+7AKECw+bA1Xfl1dfIKRUXaNjIlmDzKApJ924OFHNwjtw==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -9704,24 +9678,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "optional": true, - "peer": true - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -10995,17 +10951,6 @@ "redux": "^5.0.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",