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,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>
);
}