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 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 19:06:48 +03:00
parent d333732346
commit 13a8535b5c
6 changed files with 520 additions and 0 deletions

View File

@@ -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 (
<CodeMirror
value={value}
onChange={handleChange}
extensions={extensions}
theme="dark"
className="h-full text-sm"
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
autocompletion: true,
}}
/>
);
}

View File

@@ -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<HTMLInputElement>(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 (
<div className="flex items-center gap-1">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => {
setEditValue(e.target.value);
setIsNull(false);
}}
onKeyDown={handleKeyDown}
onBlur={() => onSave(isNull ? null : editValue)}
className="h-6 text-xs"
disabled={isNull}
/>
<button
className={`shrink-0 rounded px-1 text-[10px] ${
isNull
? "bg-muted text-muted-foreground"
: "text-muted-foreground hover:bg-muted"
}`}
onClick={() => {
setIsNull(!isNull);
if (!isNull) {
setEditValue("");
}
}}
onMouseDown={(e) => e.preventDefault()}
>
NULL
</button>
</div>
);
}

View File

@@ -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<string>;
}
export function ResultsPanel({
result,
error,
isLoading,
onCellDoubleClick,
highlightedCells,
}: Props) {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Executing query...
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center p-4">
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<pre className="whitespace-pre-wrap font-mono text-xs">{error}</pre>
</div>
</div>
);
}
if (!result) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Press Ctrl+Enter to execute query
</div>
);
}
if (result.columns.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Query executed successfully. {result.row_count} rows affected.
</div>
);
}
return (
<ResultsTable
columns={result.columns}
types={result.types}
rows={result.rows}
onCellDoubleClick={onCellDoubleClick}
highlightedCells={highlightedCells}
/>
);
}

View File

@@ -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<string>;
}
export function ResultsTable({
columns: colNames,
rows,
onCellDoubleClick,
highlightedCells,
}: Props) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnResizeMode] = useState<ColumnResizeMode>("onChange");
const parentRef = useRef<HTMLDivElement>(null);
const columns = useMemo<ColumnDef<unknown[]>[]>(
() =>
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 (
<div
className={`truncate px-2 py-1 ${
value === null
? "italic text-muted-foreground"
: isHighlighted
? "bg-yellow-900/30"
: ""
}`}
onDoubleClick={() =>
onCellDoubleClick?.(rowIndex, i, value)
}
>
{value === null ? "NULL" : String(value)}
</div>
);
},
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 (
<div ref={parentRef} className="h-full overflow-auto">
{/* Header */}
<div className="sticky top-0 z-10 flex bg-card border-b">
{table.getHeaderGroups().map((headerGroup) =>
headerGroup.headers.map((header) => (
<div
key={header.id}
className="relative shrink-0 select-none border-r px-2 py-1.5 text-left text-xs font-medium text-muted-foreground"
style={{ width: header.getSize(), minWidth: header.getSize() }}
>
<div
className="flex cursor-pointer items-center gap-1 hover:text-foreground"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" && (
<ArrowUp className="h-3 w-3" />
)}
{header.column.getIsSorted() === "desc" && (
<ArrowDown className="h-3 w-3" />
)}
</div>
{/* Resize handle */}
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onDoubleClick={() => 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" : ""
}`}
/>
</div>
))
)}
</div>
{/* Virtual rows */}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
return (
<div
key={row.id}
className="absolute left-0 flex hover:bg-accent/50"
style={{
top: `${virtualRow.start}px`,
height: `${virtualRow.size}px`,
}}
>
{row.getVisibleCells().map((cell) => {
const w = getColWidth(cell.column.id, cell.column.getSize());
return (
<div
key={cell.id}
className="shrink-0 border-b border-r text-xs"
style={{ width: w, minWidth: w }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Open a query tab or select a table to get started.
</div>
);
}
switch (activeTab.type) {
case "query":
return (
<WorkspacePanel
key={activeTab.id}
connectionId={activeTab.connectionId}
initialSql={activeTab.sql}
onSqlChange={(sql) => updateTab(activeTab.id, { sql })}
/>
);
case "table":
return (
<TableDataView
key={activeTab.id}
connectionId={activeTab.connectionId}
schema={activeTab.schema!}
table={activeTab.table!}
/>
);
case "structure":
return (
<TableStructure
key={activeTab.id}
connectionId={activeTab.connectionId}
schema={activeTab.schema!}
table={activeTab.table!}
/>
);
default:
return null;
}
}

View File

@@ -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<QueryResult | null>(null);
const [error, setError] = useState<string | null>(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 (
<ResizablePanelGroup orientation="vertical">
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-2 py-1">
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={handleExecute}
disabled={queryMutation.isPending || !sqlValue.trim()}
>
{queryMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Play className="h-3 w-3" />
)}
Run
</Button>
<span className="text-[11px] text-muted-foreground">
Ctrl+Enter to execute
</span>
</div>
<div className="flex-1 overflow-hidden">
<SqlEditor
value={sqlValue}
onChange={handleChange}
onExecute={handleExecute}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
<ResultsPanel
result={result}
error={error}
isLoading={queryMutation.isPending}
/>
</ResizablePanel>
</ResizablePanelGroup>
);
}