feat: add Greenplum 7 compatibility and AI SQL generation

Greenplum 7 (PG12-based) compatibility:
- Auto-detect GP via version() string, store DbFlavor per connection
- connect returns ConnectResult with version + flavor
- Fix pg_total_relation_size to use c.oid (universal, safer on both PG/GP)
- Branch is_identity column query for GP (lacks the column)
- Branch list_sessions wait_event fields for GP
- Exclude gp_toolkit schema in schema listing, completion, lookup, AI context
- Smart StatusBar version display: GP shows "GP 7.0.0 (PG 12.4)"
- Fix connection list spinner showing on all cards during connect

AI SQL generation (Ollama):
- Add AI settings, model selection, and generate_sql command
- Frontend AI panel with prompt input and SQL output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 18:24:06 +03:00
parent d5cff8bd5e
commit e8d99c645b
27 changed files with 1276 additions and 113 deletions

View File

@@ -0,0 +1,92 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AiSettingsPopover } from "./AiSettingsPopover";
import { useGenerateSql } from "@/hooks/use-ai";
import { Sparkles, Loader2, X } from "lucide-react";
import { toast } from "sonner";
interface Props {
connectionId: string;
onSqlGenerated: (sql: string) => void;
onClose: () => void;
onExecute?: () => void;
}
export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Props) {
const [prompt, setPrompt] = useState("");
const generateMutation = useGenerateSql();
const handleGenerate = () => {
if (!prompt.trim() || generateMutation.isPending) return;
generateMutation.mutate(
{ connectionId, prompt },
{
onSuccess: (sql) => {
onSqlGenerated(sql);
setPrompt("");
},
onError: (err) => {
toast.error("AI generation failed", { description: String(err) });
},
}
);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
onExecute?.();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
handleGenerate();
return;
}
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
return (
<div className="flex items-center gap-2 border-b bg-muted/50 px-2 py-1">
<Sparkles className="h-3.5 w-3.5 shrink-0 text-purple-500" />
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe the query you want..."
className="h-7 min-w-0 flex-1 text-xs"
autoFocus
disabled={generateMutation.isPending}
/>
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={handleGenerate}
disabled={generateMutation.isPending || !prompt.trim()}
>
{generateMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
"Generate"
)}
</Button>
<AiSettingsPopover />
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={onClose}
title="Close AI bar"
>
<X className="h-3 w-3" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAiSettings, useSaveAiSettings, useOllamaModels } from "@/hooks/use-ai";
import { Settings, RefreshCw, Loader2 } from "lucide-react";
import { toast } from "sonner";
export function AiSettingsPopover() {
const { data: settings } = useAiSettings();
const saveMutation = useSaveAiSettings();
const [url, setUrl] = useState<string | null>(null);
const [model, setModel] = useState<string | null>(null);
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
const currentModel = model ?? settings?.model ?? "";
const {
data: models,
isLoading: modelsLoading,
isError: modelsError,
refetch: refetchModels,
} = useOllamaModels(currentUrl);
const handleSave = () => {
saveMutation.mutate(
{ ollama_url: currentUrl, model: currentModel },
{
onSuccess: () => toast.success("AI settings saved"),
onError: (err) =>
toast.error("Failed to save AI settings", {
description: String(err),
}),
}
);
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
title="AI Settings"
>
<Settings className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="flex flex-col gap-3">
<h4 className="text-sm font-medium">Ollama Settings</h4>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Ollama URL</label>
<Input
value={currentUrl}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://localhost:11434"
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground">Model</label>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={() => refetchModels()}
disabled={modelsLoading}
title="Refresh models"
>
{modelsLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</div>
{modelsError ? (
<p className="text-xs text-destructive">
Cannot connect to Ollama
</p>
) : (
<Select value={currentModel} onValueChange={setModel}>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{models?.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Button size="sm" className="h-7 text-xs" onClick={handleSave}>
Save
</Button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -25,6 +25,7 @@ import {
import type { ConnectionConfig } from "@/types";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
import { ENVIRONMENTS } from "@/lib/environment";
import { useState } from "react";
interface Props {
open: boolean;
@@ -39,8 +40,10 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
const connectMutation = useConnect();
const disconnectMutation = useDisconnect();
const { connectedIds, activeConnectionId } = useAppStore();
const [connectingId, setConnectingId] = useState<string | null>(null);
const handleConnect = (conn: ConnectionConfig) => {
setConnectingId(conn.id);
connectMutation.mutate(conn, {
onSuccess: () => {
toast.success(`Connected to ${conn.name}`);
@@ -49,6 +52,9 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
onError: (err) => {
toast.error("Connection failed", { description: String(err) });
},
onSettled: () => {
setConnectingId(null);
},
});
};
@@ -169,9 +175,9 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
variant="ghost"
className="h-7 w-7"
onClick={() => handleConnect(conn)}
disabled={connectMutation.isPending}
disabled={connectingId !== null}
>
{connectMutation.isPending ? (
{connectingId === conn.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plug className="h-3.5 w-3.5" />

View File

@@ -12,13 +12,14 @@ export function HistoryPanel() {
const { data: entries } = useHistory(undefined, search || undefined);
const clearMutation = useClearHistory();
const handleClick = (sql: string, connectionId: string) => {
const handleClick = (sql: string, connectionId: string, database?: string) => {
const cid = activeConnectionId ?? connectionId;
const tab: Tab = {
id: crypto.randomUUID(),
type: "query",
title: "History Query",
connectionId: cid,
database,
sql,
};
addTab(tab);
@@ -52,7 +53,7 @@ export function HistoryPanel() {
<button
key={entry.id}
className="flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent"
onClick={() => handleClick(entry.sql, entry.connection_id)}
onClick={() => handleClick(entry.sql, entry.connection_id, entry.database || undefined)}
>
<div className="flex items-center gap-1.5">
{entry.status === "success" ? (

View File

@@ -3,6 +3,16 @@ import { useConnections } from "@/hooks/use-connections";
import { Circle } from "lucide-react";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
function formatDbVersion(version: string): string {
const gpMatch = version.match(/Greenplum Database ([\d.]+)/i);
if (gpMatch) {
const pgMatch = version.match(/^PostgreSQL ([\d.]+)/);
const pgVer = pgMatch ? ` (PG ${pgMatch[1]})` : "";
return `GP ${gpMatch[1]}${pgVer}`;
}
return version.split(",")[0]?.replace("PostgreSQL ", "PG ") ?? version;
}
interface Props {
rowCount?: number | null;
executionTime?: number | null;
@@ -46,7 +56,7 @@ export function StatusBar({ rowCount, executionTime }: Props) {
</span>
)}
{pgVersion && (
<span className="hidden sm:inline">{pgVersion.split(",")[0]?.replace("PostgreSQL ", "PG ")}</span>
<span className="hidden sm:inline">{formatDbVersion(pgVersion)}</span>
)}
</div>
<div className="flex items-center gap-3">

View File

@@ -54,6 +54,7 @@ export function AdminPanel() {
type: "roles",
title: "Roles & Users",
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
};
addTab(tab);
}}
@@ -66,6 +67,7 @@ export function AdminPanel() {
type: "sessions",
title: "Active Sessions",
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
};
addTab(tab);
}}

View File

@@ -8,7 +8,7 @@ import type { Tab } from "@/types";
export function SavedQueriesPanel() {
const [search, setSearch] = useState("");
const { activeConnectionId, addTab } = useAppStore();
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
const { data: queries } = useSavedQueries(search || undefined);
const deleteMutation = useDeleteSavedQuery();
@@ -20,6 +20,7 @@ export function SavedQueriesPanel() {
type: "query",
title: "Saved Query",
connectionId: cid,
database: currentDatabase ?? undefined,
sql,
};
addTab(tab);

View File

@@ -118,6 +118,7 @@ export function SchemaTree() {
type: "table",
title: table,
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
schema,
table,
};
@@ -129,6 +130,7 @@ export function SchemaTree() {
type: "structure",
title: `${table} (structure)`,
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
schema,
table,
};

View File

@@ -0,0 +1,87 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View File

@@ -13,7 +13,7 @@ import { useCompletionSchema } from "@/hooks/use-completion-schema";
import { useConnections } from "@/hooks/use-connections";
import { useAppStore } from "@/stores/app-store";
import { Button } from "@/components/ui/button";
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark, Table2, Braces } from "lucide-react";
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark, Table2, Braces, Sparkles } from "lucide-react";
import { format as formatSql } from "sql-formatter";
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
import {
@@ -25,6 +25,7 @@ import {
import { exportCsv, exportJson } from "@/lib/tauri";
import { save } from "@tauri-apps/plugin-dialog";
import { toast } from "sonner";
import { AiBar } from "@/components/ai/AiBar";
import type { QueryResult, ExplainResult } from "@/types";
interface Props {
@@ -51,6 +52,7 @@ export function WorkspacePanel({
const [resultView, setResultView] = useState<"results" | "explain">("results");
const [resultViewMode, setResultViewMode] = useState<"table" | "json">("table");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [aiBarOpen, setAiBarOpen] = useState(false);
const queryMutation = useQueryExecution();
const addHistoryMutation = useAddHistory();
@@ -245,6 +247,16 @@ export function WorkspacePanel({
<Bookmark className="h-3 w-3" />
Save
</Button>
<Button
size="sm"
variant={aiBarOpen ? "secondary" : "ghost"}
className="h-6 gap-1 text-xs"
onClick={() => setAiBarOpen(!aiBarOpen)}
title="AI SQL Generator"
>
<Sparkles className="h-3 w-3" />
AI
</Button>
{result && result.columns.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -277,7 +289,18 @@ export function WorkspacePanel({
</span>
)}
</div>
<div className="flex-1 overflow-hidden">
{aiBarOpen && (
<AiBar
connectionId={connectionId}
onSqlGenerated={(sql) => {
setSqlValue(sql);
onSqlChange?.(sql);
}}
onClose={() => setAiBarOpen(false)}
onExecute={handleExecute}
/>
)}
<div className="min-h-0 flex-1">
<SqlEditor
value={sqlValue}
onChange={handleChange}
@@ -290,70 +313,74 @@ export function WorkspacePanel({
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
{(explainData || result || error) && (
<div className="flex items-center border-b text-xs">
<button
className={`px-3 py-1 font-medium ${
resultView === "results"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultView("results")}
>
Results
</button>
{explainData && (
<div className="flex h-full flex-col overflow-hidden">
{(explainData || result || error) && (
<div className="flex shrink-0 items-center border-b text-xs">
<button
className={`px-3 py-1 font-medium ${
resultView === "explain"
resultView === "results"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultView("explain")}
onClick={() => setResultView("results")}
>
Explain
Results
</button>
)}
{resultView === "results" && result && result.columns.length > 0 && (
<div className="ml-auto mr-2 flex items-center rounded-md border">
{explainData && (
<button
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
resultViewMode === "table"
? "bg-muted text-foreground"
className={`px-3 py-1 font-medium ${
resultView === "explain"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultViewMode("table")}
title="Table view"
onClick={() => setResultView("explain")}
>
<Table2 className="h-3 w-3" />
Table
Explain
</button>
<button
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
resultViewMode === "json"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultViewMode("json")}
title="JSON view"
>
<Braces className="h-3 w-3" />
JSON
</button>
</div>
)}
{resultView === "results" && result && result.columns.length > 0 && (
<div className="ml-auto mr-2 flex items-center rounded-md border">
<button
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
resultViewMode === "table"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultViewMode("table")}
title="Table view"
>
<Table2 className="h-3 w-3" />
Table
</button>
<button
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
resultViewMode === "json"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultViewMode("json")}
title="JSON view"
>
<Braces className="h-3 w-3" />
JSON
</button>
</div>
)}
</div>
)}
<div className="min-h-0 flex-1">
{resultView === "explain" && explainData ? (
<ExplainView data={explainData} />
) : (
<ResultsPanel
result={result}
error={error}
isLoading={queryMutation.isPending && resultView === "results"}
viewMode={resultViewMode}
/>
)}
</div>
)}
{resultView === "explain" && explainData ? (
<ExplainView data={explainData} />
) : (
<ResultsPanel
result={result}
error={error}
isLoading={queryMutation.isPending && resultView === "results"}
viewMode={resultViewMode}
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>