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:
@@ -13,7 +13,7 @@ import { useAppStore } from "@/stores/app-store";
|
||||
import type { Tab } from "@/types";
|
||||
|
||||
export default function App() {
|
||||
const { activeConnectionId, addTab } = useAppStore();
|
||||
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
|
||||
|
||||
const handleNewQuery = useCallback(() => {
|
||||
if (!activeConnectionId) return;
|
||||
@@ -22,10 +22,11 @@ export default function App() {
|
||||
type: "query",
|
||||
title: "New Query",
|
||||
connectionId: activeConnectionId,
|
||||
database: currentDatabase ?? undefined,
|
||||
sql: "",
|
||||
};
|
||||
addTab(tab);
|
||||
}, [activeConnectionId, addTab]);
|
||||
}, [activeConnectionId, currentDatabase, addTab]);
|
||||
|
||||
const handleCloseTab = useCallback(() => {
|
||||
const { activeTabId, closeTab } = useAppStore.getState();
|
||||
|
||||
92
src/components/ai/AiBar.tsx
Normal file
92
src/components/ai/AiBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/components/ai/AiSettingsPopover.tsx
Normal file
121
src/components/ai/AiSettingsPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
87
src/components/ui/popover.tsx
Normal file
87
src/components/ui/popover.tsx
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
47
src/hooks/use-ai.ts
Normal file
47
src/hooks/use-ai.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getAiSettings,
|
||||
saveAiSettings,
|
||||
listOllamaModels,
|
||||
generateSql,
|
||||
} from "@/lib/tauri";
|
||||
import type { AiSettings } from "@/types";
|
||||
|
||||
export function useAiSettings() {
|
||||
return useQuery({
|
||||
queryKey: ["ai-settings"],
|
||||
queryFn: getAiSettings,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveAiSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (settings: AiSettings) => saveAiSettings(settings),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useOllamaModels(ollamaUrl: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["ollama-models", ollamaUrl],
|
||||
queryFn: () => listOllamaModels(ollamaUrl!),
|
||||
enabled: !!ollamaUrl,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSql() {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
connectionId,
|
||||
prompt,
|
||||
}: {
|
||||
connectionId: string;
|
||||
prompt: string;
|
||||
}) => generateSql(connectionId, prompt),
|
||||
});
|
||||
}
|
||||
@@ -51,19 +51,19 @@ export function useTestConnection() {
|
||||
}
|
||||
|
||||
export function useConnect() {
|
||||
const { addConnectedId, setActiveConnectionId, setPgVersion, setCurrentDatabase } =
|
||||
const { addConnectedId, setActiveConnectionId, setPgVersion, setDbFlavor, setCurrentDatabase } =
|
||||
useAppStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (config: ConnectionConfig) => {
|
||||
await connectDb(config);
|
||||
const version = await testConnection(config);
|
||||
return { id: config.id, version, database: config.database };
|
||||
const result = await connectDb(config);
|
||||
return { id: config.id, ...result, database: config.database };
|
||||
},
|
||||
onSuccess: ({ id, version, database }) => {
|
||||
onSuccess: ({ id, version, flavor, database }) => {
|
||||
addConnectedId(id);
|
||||
setActiveConnectionId(id);
|
||||
setPgVersion(version);
|
||||
setDbFlavor(id, flavor);
|
||||
setCurrentDatabase(database);
|
||||
},
|
||||
});
|
||||
@@ -91,17 +91,17 @@ export function useDisconnect() {
|
||||
|
||||
export function useReconnect() {
|
||||
const queryClient = useQueryClient();
|
||||
const { setPgVersion, setCurrentDatabase } = useAppStore();
|
||||
const { setPgVersion, setDbFlavor, setCurrentDatabase } = useAppStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (config: ConnectionConfig) => {
|
||||
await disconnectDb(config.id);
|
||||
await connectDb(config);
|
||||
const version = await testConnection(config);
|
||||
return { version, database: config.database };
|
||||
const result = await connectDb(config);
|
||||
return { id: config.id, ...result, database: config.database };
|
||||
},
|
||||
onSuccess: ({ version, database }) => {
|
||||
onSuccess: ({ id, version, flavor, database }) => {
|
||||
setPgVersion(version);
|
||||
setDbFlavor(id, flavor);
|
||||
setCurrentDatabase(database);
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
ConnectionConfig,
|
||||
ConnectResult,
|
||||
DbFlavor,
|
||||
QueryResult,
|
||||
PaginatedQueryResult,
|
||||
SchemaObject,
|
||||
@@ -40,7 +42,7 @@ export const testConnection = (config: ConnectionConfig) =>
|
||||
invoke<string>("test_connection", { config });
|
||||
|
||||
export const connectDb = (config: ConnectionConfig) =>
|
||||
invoke<void>("connect", { config });
|
||||
invoke<ConnectResult>("connect", { config });
|
||||
|
||||
export const disconnectDb = (id: string) =>
|
||||
invoke<void>("disconnect", { id });
|
||||
@@ -55,6 +57,9 @@ export const setReadOnly = (connectionId: string, readOnly: boolean) =>
|
||||
export const getReadOnly = (connectionId: string) =>
|
||||
invoke<boolean>("get_read_only", { connectionId });
|
||||
|
||||
export const getDbFlavor = (connectionId: string) =>
|
||||
invoke<DbFlavor>("get_db_flavor", { connectionId });
|
||||
|
||||
// Queries
|
||||
export const executeQuery = (connectionId: string, sql: string) =>
|
||||
invoke<QueryResult>("execute_query", { connectionId, sql });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { ConnectionConfig, Tab } from "@/types";
|
||||
import type { ConnectionConfig, DbFlavor, Tab } from "@/types";
|
||||
|
||||
interface AppState {
|
||||
connections: ConnectionConfig[];
|
||||
@@ -7,6 +7,7 @@ interface AppState {
|
||||
currentDatabase: string | null;
|
||||
connectedIds: Set<string>;
|
||||
readOnlyMap: Record<string, boolean>;
|
||||
dbFlavors: Record<string, DbFlavor>;
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
sidebarWidth: number;
|
||||
@@ -18,6 +19,7 @@ interface AppState {
|
||||
addConnectedId: (id: string) => void;
|
||||
removeConnectedId: (id: string) => void;
|
||||
setReadOnly: (connectionId: string, readOnly: boolean) => void;
|
||||
setDbFlavor: (connectionId: string, flavor: DbFlavor) => void;
|
||||
setPgVersion: (version: string | null) => void;
|
||||
|
||||
addTab: (tab: Tab) => void;
|
||||
@@ -33,6 +35,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
currentDatabase: null,
|
||||
connectedIds: new Set(),
|
||||
readOnlyMap: {},
|
||||
dbFlavors: {},
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
sidebarWidth: 260,
|
||||
@@ -50,13 +53,22 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
set((state) => {
|
||||
const next = new Set(state.connectedIds);
|
||||
next.delete(id);
|
||||
const { [id]: _, ...restRo } = state.readOnlyMap;
|
||||
return { connectedIds: next, readOnlyMap: restRo };
|
||||
const restRo = Object.fromEntries(
|
||||
Object.entries(state.readOnlyMap).filter(([k]) => k !== id)
|
||||
);
|
||||
const restFlavors = Object.fromEntries(
|
||||
Object.entries(state.dbFlavors).filter(([k]) => k !== id)
|
||||
);
|
||||
return { connectedIds: next, readOnlyMap: restRo, dbFlavors: restFlavors };
|
||||
}),
|
||||
setReadOnly: (connectionId, readOnly) =>
|
||||
set((state) => ({
|
||||
readOnlyMap: { ...state.readOnlyMap, [connectionId]: readOnly },
|
||||
})),
|
||||
setDbFlavor: (connectionId, flavor) =>
|
||||
set((state) => ({
|
||||
dbFlavors: { ...state.dbFlavors, [connectionId]: flavor },
|
||||
})),
|
||||
setPgVersion: (version) => set({ pgVersion: version }),
|
||||
|
||||
addTab: (tab) =>
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
export type DbFlavor = "postgresql" | "greenplum";
|
||||
|
||||
export interface ConnectResult {
|
||||
version: string;
|
||||
flavor: DbFlavor;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user