diff --git a/frontend/package.json b/frontend/package.json index 99e13674b..adc2206b4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.468.0", "mermaid": "^11.15.0", + "prismjs": "^1.30.0", "react": "^19.0.0", "react-day-picker": "^10.0.1", "react-dom": "^19.0.0", diff --git a/frontend/src/features/log-explorer/components/LogExplorerView.tsx b/frontend/src/features/log-explorer/components/LogExplorerView.tsx index 9ff8bdee2..dbf8e21ad 100644 --- a/frontend/src/features/log-explorer/components/LogExplorerView.tsx +++ b/frontend/src/features/log-explorer/components/LogExplorerView.tsx @@ -37,7 +37,7 @@ import { Input } from '@/shared/components/ui/input' import { TimeRangePicker, presetRange, type TimeRange } from '@/shared/components/ui/time-range-picker' import { ResultsHeader, ResultRow, flattenDoc } from './log-results' import { IndexPatternSelector } from './IndexPatternSelector' -import { SqlQueryEditor } from './SqlQueryEditor' +import { SqlQueryEditor } from '@/shared/components/sql-editor' import { logExplorerHttpService as svc, LogExplorerHttpError, diff --git a/frontend/src/features/log-explorer/components/SqlAutocompleteDropdown.tsx b/frontend/src/features/log-explorer/components/SqlAutocompleteDropdown.tsx deleted file mode 100644 index 1f75a4c39..000000000 --- a/frontend/src/features/log-explorer/components/SqlAutocompleteDropdown.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useEffect, useRef } from 'react' -import { cn } from '@/shared/lib/utils' -import type { Suggestion } from '../services/autocomplete-trie.service' - -interface Props { - items: Suggestion[] - activeIndex: number - position: { x: number; y: number } - onPick: (item: Suggestion) => void - onHover: (index: number) => void -} - -const ROW_HEIGHT = 28 -const MAX_VISIBLE = 8 - -export function SqlAutocompleteDropdown({ - items, - activeIndex, - position, - onPick, - onHover, -}: Props) { - const listRef = useRef(null) - - useEffect(() => { - const el = listRef.current?.children[activeIndex] as HTMLElement | undefined - el?.scrollIntoView({ block: 'nearest' }) - }, [activeIndex]) - - if (items.length === 0) return null - - return ( - - ) -} diff --git a/frontend/src/features/log-explorer/components/SqlQueryEditor.tsx b/frontend/src/features/log-explorer/components/SqlQueryEditor.tsx deleted file mode 100644 index 7b0dd2dd8..000000000 --- a/frontend/src/features/log-explorer/components/SqlQueryEditor.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import Prism from 'prismjs' -import 'prismjs/components/prism-sql' -import { cn } from '@/shared/lib/utils' -import { useSqlAutocomplete } from '../hooks/useSqlAutocomplete' -import { SqlAutocompleteDropdown } from './SqlAutocompleteDropdown' -import type { Suggestion } from '../services/autocomplete-trie.service' -import type { IndexField, IndexPattern } from '../types/log-explorer.types' - -interface Props { - value: string - onChange: (v: string) => void - onRun: () => void - fields: IndexField[] - patterns: IndexPattern[] - placeholder?: string -} - -const MIN_ROWS = 4 -const MAX_ROWS = 12 -const LINE_HEIGHT_PX = 18 -const PADDING_Y = 8 -const TOKEN_CHARS = /[A-Za-z0-9_.@-]/ -const MIN_HEIGHT = MIN_ROWS * LINE_HEIGHT_PX + PADDING_Y * 2 -const MAX_HEIGHT = MAX_ROWS * LINE_HEIGHT_PX + PADDING_Y * 2 - -interface TokenSpan { - text: string - start: number - end: number -} - -function tokenAtCaret(value: string, caret: number): TokenSpan | null { - let start = caret - while (start > 0 && TOKEN_CHARS.test(value[start - 1])) start-- - if (start === caret) return null - return { text: value.slice(start, caret), start, end: caret } -} - -function getCaretViewportCoords(textarea: HTMLTextAreaElement): { left: number; top: number } { - const mirror = document.createElement('div') - const cs = window.getComputedStyle(textarea) - const copyProps = [ - 'boxSizing', 'width', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', - 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', - 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', - 'borderLeftWidth', 'tabSize', 'textIndent', 'textTransform', 'wordSpacing', - ] as const - for (const p of copyProps) { - mirror.style.setProperty(p as string, cs.getPropertyValue(p as string)) - } - mirror.style.position = 'absolute' - mirror.style.visibility = 'hidden' - mirror.style.whiteSpace = 'pre-wrap' - mirror.style.wordWrap = 'break-word' - mirror.style.overflow = 'hidden' - mirror.style.top = '0' - mirror.style.left = '-9999px' - document.body.appendChild(mirror) - - const caret = textarea.selectionStart ?? textarea.value.length - mirror.textContent = textarea.value.slice(0, caret) - const marker = document.createElement('span') - marker.textContent = textarea.value.slice(caret, caret + 1) || '.' - mirror.appendChild(marker) - - const markerRect = marker.getBoundingClientRect() - const mirrorRect = mirror.getBoundingClientRect() - const taRect = textarea.getBoundingClientRect() - document.body.removeChild(mirror) - - return { - left: taRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft, - top: taRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop, - } -} - -export function SqlQueryEditor({ value, onChange, onRun, fields, patterns, placeholder }: Props) { - const textareaRef = useRef(null) - const wrapperRef = useRef(null) - const preRef = useRef(null) - const [height, setHeight] = useState(MIN_HEIGHT) - const [open, setOpen] = useState(false) - const [items, setItems] = useState([]) - const [activeIndex, setActiveIndex] = useState(0) - const [anchor, setAnchor] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) - const tokenRef = useRef(null) - - const { suggest } = useSqlAutocomplete(fields, patterns) - - const highlighted = useMemo(() => { - const grammar = Prism.languages.sql - if (!grammar) return value - return Prism.highlight(value + '\n', grammar, 'sql') - }, [value]) - - const recalcHeight = useCallback(() => { - const ta = textareaRef.current - if (!ta) return - ta.style.height = 'auto' - const next = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, ta.scrollHeight)) - ta.style.height = '' - setHeight(next) - }, []) - - useLayoutEffect(() => { - recalcHeight() - }, [value, recalcHeight]) - - const closeDropdown = useCallback(() => { - setOpen(false) - setItems([]) - tokenRef.current = null - }, []) - - const refreshSuggestions = useCallback(() => { - const ta = textareaRef.current - if (!ta) return - const caret = ta.selectionStart ?? 0 - const token = tokenAtCaret(value, caret) - if (!token) { - closeDropdown() - return - } - const next = suggest(token.text, 30).filter( - (s) => s.word.toLowerCase() !== token.text.toLowerCase(), - ) - if (next.length === 0) { - closeDropdown() - return - } - tokenRef.current = token - setItems(next) - setActiveIndex(0) - const coords = getCaretViewportCoords(ta) - setAnchor({ x: coords.left, y: coords.top + LINE_HEIGHT_PX + 4 }) - setOpen(true) - }, [closeDropdown, suggest, value]) - - const accept = useCallback( - (item: Suggestion) => { - const ta = textareaRef.current - const span = tokenRef.current - if (!ta || !span) return - const next = value.slice(0, span.start) + item.word + value.slice(span.end) - onChange(next) - closeDropdown() - const newCaret = span.start + item.word.length - requestAnimationFrame(() => { - const t = textareaRef.current - if (!t) return - t.focus() - t.setSelectionRange(newCaret, newCaret) - }) - }, - [closeDropdown, onChange, value], - ) - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { - e.preventDefault() - closeDropdown() - onRun() - return - } - if (open) { - if (e.key === 'ArrowDown') { - e.preventDefault() - setActiveIndex((i) => (i + 1) % items.length) - return - } - if (e.key === 'ArrowUp') { - e.preventDefault() - setActiveIndex((i) => (i - 1 + items.length) % items.length) - return - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault() - const picked = items[activeIndex] - if (picked) accept(picked) - return - } - if (e.key === 'Escape') { - e.preventDefault() - closeDropdown() - return - } - } else if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - onRun() - } - }, - [accept, activeIndex, closeDropdown, items, onRun, open], - ) - - const handleChange = useCallback( - (e: React.ChangeEvent) => { - onChange(e.target.value) - }, - [onChange], - ) - - - useEffect(() => { - if (document.activeElement === textareaRef.current) refreshSuggestions() - }, [value, refreshSuggestions]) - - useEffect(() => { - if (!open) return - const onMouseDown = (e: MouseEvent) => { - const root = wrapperRef.current - if (!root) return - if (!root.contains(e.target as Node)) closeDropdown() - } - document.addEventListener('mousedown', onMouseDown) - return () => document.removeEventListener('mousedown', onMouseDown) - }, [open, closeDropdown]) - - const syncScroll = useCallback(() => { - const ta = textareaRef.current - const pre = preRef.current - if (!ta || !pre) return - pre.scrollTop = ta.scrollTop - pre.scrollLeft = ta.scrollLeft - }, []) - - return ( -
- -
-        
-      
-