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