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:
58
src/components/editor/SqlEditor.tsx
Normal file
58
src/components/editor/SqlEditor.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/results/EditableCell.tsx
Normal file
62
src/components/results/EditableCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/results/ResultsPanel.tsx
Normal file
69
src/components/results/ResultsPanel.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/components/results/ResultsTable.tsx
Normal file
180
src/components/results/ResultsTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/workspace/TabContent.tsx
Normal file
49
src/components/workspace/TabContent.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/components/workspace/WorkspacePanel.tsx
Normal file
102
src/components/workspace/WorkspacePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user