From 13a8535b5c0b90e5f3726b35f5c8d744b57cd98f Mon Sep 17 00:00:00 2001 From: "A.Shakhmatov" Date: Wed, 11 Feb 2026 19:06:48 +0300 Subject: [PATCH] feat: add SQL editor, query results, and workspace panels Add CodeMirror 6 SQL editor with Ctrl+Enter execution, ResultsTable with virtual scrolling and resizable columns, ResultsPanel, EditableCell, WorkspacePanel (editor + results split), and TabContent router. Co-Authored-By: Claude Opus 4.6 --- src/components/editor/SqlEditor.tsx | 58 +++++++ src/components/results/EditableCell.tsx | 62 +++++++ src/components/results/ResultsPanel.tsx | 69 ++++++++ src/components/results/ResultsTable.tsx | 180 ++++++++++++++++++++ src/components/workspace/TabContent.tsx | 49 ++++++ src/components/workspace/WorkspacePanel.tsx | 102 +++++++++++ 6 files changed, 520 insertions(+) create mode 100644 src/components/editor/SqlEditor.tsx create mode 100644 src/components/results/EditableCell.tsx create mode 100644 src/components/results/ResultsPanel.tsx create mode 100644 src/components/results/ResultsTable.tsx create mode 100644 src/components/workspace/TabContent.tsx create mode 100644 src/components/workspace/WorkspacePanel.tsx diff --git a/src/components/editor/SqlEditor.tsx b/src/components/editor/SqlEditor.tsx new file mode 100644 index 0000000..4869e93 --- /dev/null +++ b/src/components/editor/SqlEditor.tsx @@ -0,0 +1,58 @@ +import CodeMirror from "@uiw/react-codemirror"; +import { sql, PostgreSQL } from "@codemirror/lang-sql"; +import { keymap } from "@codemirror/view"; +import { useCallback, useMemo } from "react"; + +interface Props { + value: string; + onChange: (value: string) => void; + onExecute: () => void; +} + +export function SqlEditor({ value, onChange, onExecute }: Props) { + const handleChange = useCallback( + (val: string) => { + onChange(val); + }, + [onChange] + ); + + const extensions = useMemo( + () => [ + sql({ dialect: PostgreSQL }), + keymap.of([ + { + key: "Ctrl-Enter", + run: () => { + onExecute(); + return true; + }, + }, + { + key: "Mod-Enter", + run: () => { + onExecute(); + return true; + }, + }, + ]), + ], + [onExecute] + ); + + return ( + + ); +} diff --git a/src/components/results/EditableCell.tsx b/src/components/results/EditableCell.tsx new file mode 100644 index 0000000..ff97242 --- /dev/null +++ b/src/components/results/EditableCell.tsx @@ -0,0 +1,62 @@ +import { useState, useRef, useEffect } from "react"; +import { Input } from "@/components/ui/input"; + +interface Props { + value: unknown; + onSave: (value: string | null) => void; + onCancel: () => void; +} + +export function EditableCell({ value, onSave, onCancel }: Props) { + const [editValue, setEditValue] = useState( + value === null ? "" : String(value) + ); + const [isNull, setIsNull] = useState(value === null); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + onSave(isNull ? null : editValue); + } else if (e.key === "Escape") { + onCancel(); + } + }; + + return ( +
+ { + setEditValue(e.target.value); + setIsNull(false); + }} + onKeyDown={handleKeyDown} + onBlur={() => onSave(isNull ? null : editValue)} + className="h-6 text-xs" + disabled={isNull} + /> + +
+ ); +} diff --git a/src/components/results/ResultsPanel.tsx b/src/components/results/ResultsPanel.tsx new file mode 100644 index 0000000..9566b15 --- /dev/null +++ b/src/components/results/ResultsPanel.tsx @@ -0,0 +1,69 @@ +import { ResultsTable } from "./ResultsTable"; +import type { QueryResult } from "@/types"; +import { Loader2, AlertCircle } from "lucide-react"; + +interface Props { + result?: QueryResult | null; + error?: string | null; + isLoading?: boolean; + onCellDoubleClick?: ( + rowIndex: number, + colIndex: number, + value: unknown + ) => void; + highlightedCells?: Set; +} + +export function ResultsPanel({ + result, + error, + isLoading, + onCellDoubleClick, + highlightedCells, +}: Props) { + if (isLoading) { + return ( +
+ + Executing query... +
+ ); + } + + if (error) { + return ( +
+
+ +
{error}
+
+
+ ); + } + + if (!result) { + return ( +
+ Press Ctrl+Enter to execute query +
+ ); + } + + if (result.columns.length === 0) { + return ( +
+ Query executed successfully. {result.row_count} rows affected. +
+ ); + } + + return ( + + ); +} diff --git a/src/components/results/ResultsTable.tsx b/src/components/results/ResultsTable.tsx new file mode 100644 index 0000000..66d6edc --- /dev/null +++ b/src/components/results/ResultsTable.tsx @@ -0,0 +1,180 @@ +import { useMemo, useRef, useState, useCallback } from "react"; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + flexRender, + type ColumnDef, + type SortingState, + type ColumnResizeMode, +} from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { ArrowUp, ArrowDown } from "lucide-react"; + +interface Props { + columns: string[]; + types: string[]; + rows: unknown[][]; + onCellDoubleClick?: ( + rowIndex: number, + colIndex: number, + value: unknown + ) => void; + highlightedCells?: Set; +} + +export function ResultsTable({ + columns: colNames, + rows, + onCellDoubleClick, + highlightedCells, +}: Props) { + const [sorting, setSorting] = useState([]); + const [columnResizeMode] = useState("onChange"); + const parentRef = useRef(null); + + const columns = useMemo[]>( + () => + colNames.map((name, i) => ({ + id: name, + accessorFn: (row: unknown[]) => row[i], + header: name, + cell: ({ getValue, row: tableRow }) => { + const value = getValue(); + const rowIndex = tableRow.index; + const key = `${rowIndex}:${i}`; + const isHighlighted = highlightedCells?.has(key); + + return ( +
+ onCellDoubleClick?.(rowIndex, i, value) + } + > + {value === null ? "NULL" : String(value)} +
+ ); + }, + size: 150, + minSize: 50, + maxSize: 800, + })), + [colNames, onCellDoubleClick, highlightedCells] + ); + + const table = useReactTable({ + data: rows, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + columnResizeMode, + enableColumnResizing: true, + }); + + const { rows: tableRows } = table.getRowModel(); + + const virtualizer = useVirtualizer({ + count: tableRows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 28, + overscan: 20, + }); + + const columnSizing = table.getState().columnSizing; + + const getColWidth = useCallback( + (colId: string, fallback: number) => columnSizing[colId] ?? fallback, + [columnSizing] + ); + + if (colNames.length === 0) return null; + + return ( +
+ {/* Header */} +
+ {table.getHeaderGroups().map((headerGroup) => + headerGroup.headers.map((header) => ( +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {header.column.getIsSorted() === "asc" && ( + + )} + {header.column.getIsSorted() === "desc" && ( + + )} +
+ {/* Resize handle */} +
header.column.resetSize()} + className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary ${ + header.column.getIsResizing() ? "bg-primary" : "" + }`} + /> +
+ )) + )} +
+ + {/* Virtual rows */} +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = tableRows[virtualRow.index]; + return ( +
+ {row.getVisibleCells().map((cell) => { + const w = getColWidth(cell.column.id, cell.column.getSize()); + return ( +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/workspace/TabContent.tsx b/src/components/workspace/TabContent.tsx new file mode 100644 index 0000000..f9d4315 --- /dev/null +++ b/src/components/workspace/TabContent.tsx @@ -0,0 +1,49 @@ +import { useAppStore } from "@/stores/app-store"; +import { WorkspacePanel } from "./WorkspacePanel"; +import { TableDataView } from "@/components/table-viewer/TableDataView"; +import { TableStructure } from "@/components/table-viewer/TableStructure"; + +export function TabContent() { + const { tabs, activeTabId, updateTab } = useAppStore(); + const activeTab = tabs.find((t) => t.id === activeTabId); + + if (!activeTab) { + return ( +
+ Open a query tab or select a table to get started. +
+ ); + } + + switch (activeTab.type) { + case "query": + return ( + updateTab(activeTab.id, { sql })} + /> + ); + case "table": + return ( + + ); + case "structure": + return ( + + ); + default: + return null; + } +} diff --git a/src/components/workspace/WorkspacePanel.tsx b/src/components/workspace/WorkspacePanel.tsx new file mode 100644 index 0000000..92e2035 --- /dev/null +++ b/src/components/workspace/WorkspacePanel.tsx @@ -0,0 +1,102 @@ +import { useState, useCallback } from "react"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { SqlEditor } from "@/components/editor/SqlEditor"; +import { ResultsPanel } from "@/components/results/ResultsPanel"; +import { useQueryExecution } from "@/hooks/use-query-execution"; +import { Button } from "@/components/ui/button"; +import { Play, Loader2 } from "lucide-react"; +import type { QueryResult } from "@/types"; + +interface Props { + connectionId: string; + initialSql?: string; + onSqlChange?: (sql: string) => void; + onResult?: (result: QueryResult | null, error: string | null) => void; +} + +export function WorkspacePanel({ + connectionId, + initialSql = "", + onSqlChange, + onResult, +}: Props) { + const [sqlValue, setSqlValue] = useState(initialSql); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const queryMutation = useQueryExecution(); + + const handleChange = useCallback( + (val: string) => { + setSqlValue(val); + onSqlChange?.(val); + }, + [onSqlChange] + ); + + const handleExecute = useCallback(() => { + if (!sqlValue.trim() || !connectionId) return; + setError(null); + queryMutation.mutate( + { connectionId, sql: sqlValue }, + { + onSuccess: (data) => { + setResult(data); + setError(null); + onResult?.(data, null); + }, + onError: (err) => { + setResult(null); + setError(String(err)); + onResult?.(null, String(err)); + }, + } + ); + }, [connectionId, sqlValue, queryMutation, onResult]); + + return ( + + +
+
+ + + Ctrl+Enter to execute + +
+
+ +
+
+
+ + + + +
+ ); +}