feat: add column sort, SQL formatter, table stats, insert dialog, saved queries & sessions monitor

- Column sort by header click in table view (ASC/DESC/none cycle, server-side)
- SQL formatter with Format button and Shift+Alt+F keybinding (sql-formatter)
- Table size and row count display in schema tree via pg_class
- Insert row dialog with column type hints and auto-skip for identity columns
- Saved queries (bookmarks) with CRUD backend, sidebar panel, and save dialog
- Active sessions monitor (pg_stat_activity) with auto-refresh, cancel & terminate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 11:52:05 +03:00
parent ab72eeee80
commit 9d54167023
29 changed files with 1223 additions and 18 deletions

View File

@@ -7,6 +7,7 @@ interface Props {
value: string;
onChange: (value: string) => void;
onExecute: () => void;
onFormat?: () => void;
schema?: Record<string, Record<string, string[]>>;
}
@@ -25,7 +26,7 @@ function buildSqlNamespace(
return ns;
}
export function SqlEditor({ value, onChange, onExecute, schema }: Props) {
export function SqlEditor({ value, onChange, onExecute, onFormat, schema }: Props) {
const handleChange = useCallback(
(val: string) => {
onChange(val);
@@ -56,9 +57,16 @@ export function SqlEditor({ value, onChange, onExecute, schema }: Props) {
return true;
},
},
{
key: "Shift-Alt-f",
run: () => {
onFormat?.();
return true;
},
},
]),
];
}, [onExecute, schema]);
}, [onExecute, onFormat, schema]);
return (
<CodeMirror

View File

@@ -2,10 +2,11 @@ import { useState } from "react";
import { Input } from "@/components/ui/input";
import { SchemaTree } from "@/components/schema/SchemaTree";
import { HistoryPanel } from "@/components/history/HistoryPanel";
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
import { AdminPanel } from "@/components/management/AdminPanel";
import { Search } from "lucide-react";
type SidebarView = "schema" | "history" | "admin";
type SidebarView = "schema" | "history" | "saved" | "admin";
export function Sidebar() {
const [view, setView] = useState<SidebarView>("schema");
@@ -34,6 +35,16 @@ export function Sidebar() {
>
History
</button>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "saved"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setView("saved")}
>
Saved
</button>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "admin"
@@ -65,6 +76,8 @@ export function Sidebar() {
</>
) : view === "history" ? (
<HistoryPanel />
) : view === "saved" ? (
<SavedQueriesPanel />
) : (
<AdminPanel />
)}

View File

@@ -1,7 +1,7 @@
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { ScrollArea } from "@/components/ui/scroll-area";
import { X, Table2, Code, Columns, Users } from "lucide-react";
import { X, Table2, Code, Columns, Users, Activity } from "lucide-react";
export function TabBar() {
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
@@ -14,6 +14,7 @@ export function TabBar() {
table: <Table2 className="h-3 w-3" />,
structure: <Columns className="h-3 w-3" />,
roles: <Users className="h-3 w-3" />,
sessions: <Activity className="h-3 w-3" />,
};
return (

View File

@@ -20,6 +20,7 @@ import {
ChevronRight,
HardDrive,
Users,
Activity,
Loader2,
} from "lucide-react";
import type { Tab, RoleInfo } from "@/types";
@@ -57,6 +58,18 @@ export function AdminPanel() {
addTab(tab);
}}
/>
<SessionsSection
connectionId={activeConnectionId}
onOpenSessions={() => {
const tab: Tab = {
id: crypto.randomUUID(),
type: "sessions",
title: "Active Sessions",
connectionId: activeConnectionId,
};
addTab(tab);
}}
/>
</div>
);
}
@@ -298,3 +311,34 @@ function RolesSection({
</div>
);
}
function SessionsSection({
connectionId,
onOpenSessions,
}: {
connectionId: string;
onOpenSessions: () => void;
}) {
return (
<div className="border-b">
<div className="flex items-center gap-1 px-3 py-2">
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Sessions</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-[10px]"
onClick={onOpenSessions}
title="View active sessions"
>
View Sessions
</Button>
</div>
<div className="px-6 pb-2 text-xs text-muted-foreground">
Monitor active database connections and running queries.
{/* The connectionId is used by parent to open the sessions tab */}
<span className="hidden">{connectionId}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import {
useSessions,
useCancelQuery,
useTerminateBackend,
} from "@/hooks/use-management";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Loader2, XCircle, Skull, RefreshCw } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
interface Props {
connectionId: string;
}
function getStateBadge(state: string | null) {
if (!state) return null;
const colors: Record<string, string> = {
idle: "bg-green-500/15 text-green-600",
active: "bg-yellow-500/15 text-yellow-600",
"idle in transaction": "bg-orange-500/15 text-orange-600",
disabled: "bg-red-500/15 text-red-600",
};
return (
<Badge variant="outline" className={`text-[9px] px-1 py-0 ${colors[state] ?? ""}`}>
{state}
</Badge>
);
}
function formatDuration(queryStart: string | null): string {
if (!queryStart) return "-";
const start = new Date(queryStart).getTime();
const now = Date.now();
const diffSec = Math.floor((now - start) / 1000);
if (diffSec < 0) return "-";
if (diffSec < 60) return `${diffSec}s`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ${diffSec % 60}s`;
return `${Math.floor(diffSec / 3600)}h ${Math.floor((diffSec % 3600) / 60)}m`;
}
function getDurationColor(queryStart: string | null, state: string | null): string {
if (state !== "active" || !queryStart) return "";
const diffSec = (Date.now() - new Date(queryStart).getTime()) / 1000;
if (diffSec > 30) return "text-red-500 font-semibold";
if (diffSec > 5) return "text-yellow-500 font-semibold";
return "";
}
export function SessionsView({ connectionId }: Props) {
const { data: sessions, isLoading } = useSessions(connectionId);
const cancelMutation = useCancelQuery();
const terminateMutation = useTerminateBackend();
const queryClient = useQueryClient();
const handleCancel = (pid: number) => {
cancelMutation.mutate(
{ connectionId, pid },
{
onSuccess: () => toast.success(`Cancel signal sent to PID ${pid}`),
onError: (err) => toast.error("Cancel failed", { description: String(err) }),
}
);
};
const handleTerminate = (pid: number) => {
if (!confirm(`Terminate backend PID ${pid}? This will kill the session.`)) return;
terminateMutation.mutate(
{ connectionId, pid },
{
onSuccess: () => toast.success(`Terminate signal sent to PID ${pid}`),
onError: (err) => toast.error("Terminate failed", { description: String(err) }),
}
);
};
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" />
Loading sessions...
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className="text-xs font-semibold">Active Sessions</span>
<Badge variant="secondary" className="text-[10px]">
{sessions?.length ?? 0}
</Badge>
<span className="text-[10px] text-muted-foreground">Auto-refresh: 5s</span>
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 gap-1 text-xs"
onClick={() => queryClient.invalidateQueries({ queryKey: ["sessions"] })}
>
<RefreshCw className="h-3 w-3" />
Refresh
</Button>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card border-b">
<tr>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">PID</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">User</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Database</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">State</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Duration</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Wait</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Query</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Client</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{sessions?.map((s) => (
<tr key={s.pid} className="border-b hover:bg-accent/50">
<td className="px-2 py-1 font-mono">{s.pid}</td>
<td className="px-2 py-1">{s.usename ?? "-"}</td>
<td className="px-2 py-1">{s.datname ?? "-"}</td>
<td className="px-2 py-1">{getStateBadge(s.state)}</td>
<td className={`px-2 py-1 ${getDurationColor(s.query_start, s.state)}`}>
{formatDuration(s.query_start)}
</td>
<td className="px-2 py-1 text-muted-foreground">
{s.wait_event ? `${s.wait_event_type}: ${s.wait_event}` : "-"}
</td>
<td className="px-2 py-1 max-w-xs truncate font-mono" title={s.query ?? ""}>
{s.query ? (s.query.length > 60 ? s.query.slice(0, 60) + "..." : s.query) : "-"}
</td>
<td className="px-2 py-1 text-muted-foreground">{s.client_addr ?? "-"}</td>
<td className="px-2 py-1">
<div className="flex gap-0.5">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
title="Cancel Query"
onClick={() => handleCancel(s.pid)}
>
<XCircle className="h-3 w-3 text-yellow-500" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
title="Terminate Backend"
onClick={() => handleTerminate(s.pid)}
>
<Skull className="h-3 w-3 text-red-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{(!sessions || sessions.length === 0) && (
<div className="py-8 text-center text-xs text-muted-foreground">
No active sessions
</div>
)}
</div>
</div>
);
}

View File

@@ -11,6 +11,12 @@ import {
import { useVirtualizer } from "@tanstack/react-virtual";
import { ArrowUp, ArrowDown } from "lucide-react";
interface ExternalSort {
column: string | undefined;
direction: string | undefined;
onSort: (column: string | undefined, direction: string | undefined) => void;
}
interface Props {
columns: string[];
types: string[];
@@ -21,6 +27,7 @@ interface Props {
value: unknown
) => void;
highlightedCells?: Set<string>;
externalSort?: ExternalSort;
}
export function ResultsTable({
@@ -28,6 +35,7 @@ export function ResultsTable({
rows,
onCellDoubleClick,
highlightedCells,
externalSort,
}: Props) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnResizeMode] = useState<ColumnResizeMode>("onChange");
@@ -96,6 +104,38 @@ export function ResultsTable({
[columnSizing]
);
const handleHeaderClick = useCallback(
(colName: string, defaultHandler: ((e: unknown) => void) | undefined) =>
(e: unknown) => {
if (externalSort) {
// Cycle: none → ASC → DESC → none
if (externalSort.column !== colName) {
externalSort.onSort(colName, "ASC");
} else if (externalSort.direction === "ASC") {
externalSort.onSort(colName, "DESC");
} else {
externalSort.onSort(undefined, undefined);
}
} else {
defaultHandler?.(e);
}
},
[externalSort]
);
const getIsSorted = useCallback(
(colName: string, localSorted: false | "asc" | "desc") => {
if (externalSort) {
if (externalSort.column === colName) {
return externalSort.direction === "ASC" ? "asc" : externalSort.direction === "DESC" ? "desc" : false;
}
return false;
}
return localSorted;
},
[externalSort]
);
if (colNames.length === 0) return null;
return (
@@ -111,16 +151,16 @@ export function ResultsTable({
>
<div
className="flex cursor-pointer items-center gap-1 hover:text-foreground"
onClick={header.column.getToggleSortingHandler()}
onClick={handleHeaderClick(header.column.id, header.column.getToggleSortingHandler())}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" && (
{getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && (
<ArrowUp className="h-3 w-3" />
)}
{header.column.getIsSorted() === "desc" && (
{getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && (
<ArrowDown className="h-3 w-3" />
)}
</div>

View File

@@ -0,0 +1,94 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useSaveQuery } from "@/hooks/use-saved-queries";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
sql: string;
connectionId: string;
}
export function SaveQueryDialog({ open, onOpenChange, sql, connectionId }: Props) {
const [name, setName] = useState("");
const saveMutation = useSaveQuery();
useEffect(() => {
if (open) setName("");
}, [open]);
const handleSave = () => {
if (!name.trim()) {
toast.error("Query name is required");
return;
}
saveMutation.mutate(
{
id: crypto.randomUUID(),
name: name.trim(),
sql,
connection_id: connectionId,
created_at: new Date().toISOString(),
},
{
onSuccess: () => {
toast.success(`Query "${name}" saved`);
onOpenChange(false);
},
onError: (err) => {
toast.error("Failed to save query", { description: String(err) });
},
}
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Save Query</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Name</label>
<Input
className="col-span-3"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My query"
onKeyDown={(e) => e.key === "Enter" && handleSave()}
autoFocus
/>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">SQL</label>
<pre className="col-span-3 rounded bg-muted p-2 text-xs max-h-32 overflow-auto font-mono">
{sql.length > 200 ? sql.slice(0, 200) + "..." : sql}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,81 @@
import { useState } from "react";
import { useSavedQueries, useDeleteSavedQuery } from "@/hooks/use-saved-queries";
import { useAppStore } from "@/stores/app-store";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, Trash2, Bookmark } from "lucide-react";
import type { Tab } from "@/types";
export function SavedQueriesPanel() {
const [search, setSearch] = useState("");
const { activeConnectionId, addTab } = useAppStore();
const { data: queries } = useSavedQueries(search || undefined);
const deleteMutation = useDeleteSavedQuery();
const handleOpen = (sql: string, connectionId?: string) => {
const cid = activeConnectionId ?? connectionId ?? "";
if (!cid) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "query",
title: "Saved Query",
connectionId: cid,
sql,
};
addTab(tab);
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-1 p-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search saved queries..."
className="h-7 pl-7 text-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{queries?.map((query) => (
<div
key={query.id}
className="group flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent cursor-pointer"
onDoubleClick={() => handleOpen(query.sql, query.connection_id)}
>
<div className="flex items-center gap-1.5">
<Bookmark className="h-3 w-3 shrink-0 text-blue-400" />
<span className="truncate font-medium text-foreground">
{query.name}
</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate(query.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<span className="truncate font-mono text-muted-foreground">
{query.sql.length > 80 ? query.sql.slice(0, 80) + "..." : query.sql}
</span>
<span className="text-muted-foreground">
{new Date(query.created_at).toLocaleDateString()}
</span>
</div>
))}
{(!queries || queries.length === 0) && (
<div className="py-8 text-center text-xs text-muted-foreground">
No saved queries
</div>
)}
</div>
</div>
);
}

View File

@@ -31,7 +31,33 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { GrantRevokeDialog } from "@/components/management/GrantRevokeDialog";
import type { Tab } from "@/types";
import type { Tab, SchemaObject } from "@/types";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatCount(n: number): string {
if (n < 0) return "~0";
if (n < 1000) return `~${n}`;
if (n < 1_000_000) return `~${(n / 1000).toFixed(1)}k`;
return `~${(n / 1_000_000).toFixed(1)}M`;
}
function TableSizeInfo({ item }: { item: SchemaObject }) {
if (item.row_count == null && item.size_bytes == null) return null;
const parts: string[] = [];
if (item.row_count != null) parts.push(formatCount(item.row_count));
if (item.size_bytes != null) parts.push(formatSize(item.size_bytes));
return (
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
{parts.join(", ")}
</span>
);
}
export function SchemaTree() {
const { activeConnectionId, currentDatabase, setCurrentDatabase, addTab } =
@@ -407,6 +433,7 @@ function CategoryNode({
<span className="w-3.5 shrink-0" />
{icon}
<span className="truncate">{item.name}</span>
{category === "tables" && <TableSizeInfo item={item} />}
</div>
</ContextMenuTrigger>
<ContextMenuContent>

View File

@@ -0,0 +1,162 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { useColumnDetails } from "@/hooks/use-schema";
import { insertRow } from "@/lib/tauri";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connectionId: string;
schema: string;
table: string;
onSuccess: () => void;
}
export function InsertRowDialog({
open,
onOpenChange,
connectionId,
schema,
table,
onSuccess,
}: Props) {
const { data: columns } = useColumnDetails(
open ? connectionId : null,
open ? schema : null,
open ? table : null
);
const [values, setValues] = useState<Record<string, string>>({});
const [skipColumns, setSkipColumns] = useState<Set<string>>(new Set());
const [isInserting, setIsInserting] = useState(false);
useEffect(() => {
if (open && columns) {
const initial: Record<string, string> = {};
const skip = new Set<string>();
for (const col of columns) {
if (col.is_identity || col.column_default?.startsWith("nextval(")) {
skip.add(col.column_name);
} else if (col.column_default != null) {
initial[col.column_name] = col.column_default;
} else {
initial[col.column_name] = "";
}
}
setValues(initial);
setSkipColumns(skip);
}
}, [open, columns]);
const handleInsert = async () => {
if (!columns) return;
setIsInserting(true);
try {
const cols: string[] = [];
const vals: unknown[] = [];
for (const col of columns) {
if (skipColumns.has(col.column_name)) continue;
const val = values[col.column_name];
if (val === "" && col.is_nullable) {
cols.push(col.column_name);
vals.push(null);
} else if (val !== undefined) {
cols.push(col.column_name);
vals.push(val);
}
}
await insertRow({ connectionId, schema, table, columns: cols, values: vals });
toast.success("Row inserted");
onOpenChange(false);
onSuccess();
} catch (err) {
toast.error("Insert failed", { description: String(err) });
} finally {
setIsInserting(false);
}
};
const toggleSkip = (colName: string) => {
setSkipColumns((prev) => {
const next = new Set(prev);
if (next.has(colName)) {
next.delete(colName);
} else {
next.add(colName);
}
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Insert Row into {table}</DialogTitle>
</DialogHeader>
<div className="grid gap-2 py-2">
{columns?.map((col) => {
const isSkipped = skipColumns.has(col.column_name);
return (
<div key={col.column_name} className="grid grid-cols-4 items-center gap-2">
<div className="flex items-center gap-1 col-span-1 justify-end">
<span className="text-sm truncate">{col.column_name}</span>
<Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">
{col.data_type}
</Badge>
</div>
<div className="col-span-3 flex items-center gap-1">
{isSkipped ? (
<span className="text-xs text-muted-foreground italic flex-1 px-2">
auto-generated
</span>
) : (
<Input
className="flex-1 text-sm"
value={values[col.column_name] ?? ""}
onChange={(e) =>
setValues((prev) => ({ ...prev, [col.column_name]: e.target.value }))
}
placeholder={
col.is_nullable ? "NULL" : col.column_default ?? ""
}
/>
)}
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] shrink-0"
onClick={() => toggleSkip(col.column_name)}
>
{isSkipped ? "Include" : "Skip"}
</Button>
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleInsert} disabled={isInserting}>
{isInserting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Insert
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -9,7 +9,8 @@ import { getTableColumns } from "@/lib/tauri";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAppStore } from "@/stores/app-store";
import { toast } from "sonner";
import { Save, RotateCcw, Filter, Loader2, Lock, Download } from "lucide-react";
import { Save, RotateCcw, Filter, Loader2, Lock, Download, Plus } from "lucide-react";
import { InsertRowDialog } from "./InsertRowDialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -31,14 +32,15 @@ export function TableDataView({ connectionId, schema, table }: Props) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [sortColumn, _setSortColumn] = useState<string | undefined>();
const [sortDirection, _setSortDirection] = useState<string | undefined>();
const [sortColumn, setSortColumn] = useState<string | undefined>();
const [sortDirection, setSortDirection] = useState<string | undefined>();
const [filter, setFilter] = useState("");
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
const [pendingChanges, setPendingChanges] = useState<
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
>(new Map());
const [isSaving, setIsSaving] = useState(false);
const [insertDialogOpen, setInsertDialogOpen] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading, error } = useTableData({
@@ -155,6 +157,15 @@ export function TableDataView({ connectionId, schema, table }: Props) {
[data, table]
);
const handleSort = useCallback(
(column: string | undefined, direction: string | undefined) => {
setSortColumn(column);
setSortDirection(direction);
setPage(1);
},
[]
);
const handleApplyFilter = () => {
setAppliedFilter(filter || undefined);
setPage(1);
@@ -207,6 +218,17 @@ export function TableDataView({ connectionId, schema, table }: Props) {
</DropdownMenuContent>
</DropdownMenu>
)}
{!isReadOnly && (
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={() => setInsertDialogOpen(true)}
>
<Plus className="h-3 w-3" />
Insert Row
</Button>
)}
{pendingChanges.size > 0 && (
<>
<Button
@@ -253,6 +275,11 @@ export function TableDataView({ connectionId, schema, table }: Props) {
rows={data.rows}
onCellDoubleClick={handleCellDoubleClick}
highlightedCells={highlightedCells}
externalSort={{
column: sortColumn,
direction: sortDirection,
onSort: handleSort,
}}
/>
) : null}
</div>
@@ -269,6 +296,19 @@ export function TableDataView({ connectionId, schema, table }: Props) {
}}
/>
)}
<InsertRowDialog
open={insertDialogOpen}
onOpenChange={setInsertDialogOpen}
connectionId={connectionId}
schema={schema}
table={table}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ["table-data", connectionId],
});
}}
/>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { WorkspacePanel } from "./WorkspacePanel";
import { TableDataView } from "@/components/table-viewer/TableDataView";
import { TableStructure } from "@/components/table-viewer/TableStructure";
import { RoleManagerView } from "@/components/management/RoleManagerView";
import { SessionsView } from "@/components/management/SessionsView";
export function TabContent() {
const { tabs, activeTabId, updateTab } = useAppStore();
@@ -51,6 +52,13 @@ export function TabContent() {
connectionId={activeTab.connectionId}
/>
);
case "sessions":
return (
<SessionsView
key={activeTab.id}
connectionId={activeTab.connectionId}
/>
);
default:
return null;
}

View File

@@ -13,7 +13,9 @@ 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 } from "lucide-react";
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark } from "lucide-react";
import { format as formatSql } from "sql-formatter";
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -47,6 +49,7 @@ export function WorkspacePanel({
const [error, setError] = useState<string | null>(null);
const [explainData, setExplainData] = useState<ExplainResult | null>(null);
const [resultView, setResultView] = useState<"results" | "explain">("results");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const queryMutation = useQueryExecution();
const addHistoryMutation = useAddHistory();
@@ -150,6 +153,17 @@ export function WorkspacePanel({
);
}, [connectionId, sqlValue, queryMutation]);
const handleFormat = useCallback(() => {
if (!sqlValue.trim()) return;
try {
const formatted = formatSql(sqlValue, { language: "postgresql" });
setSqlValue(formatted);
onSqlChange?.(formatted);
} catch {
// Silently ignore format errors on invalid SQL
}
}, [sqlValue, onSqlChange]);
const handleExport = useCallback(
async (format: "csv" | "json") => {
if (!result || result.columns.length === 0) return;
@@ -175,6 +189,7 @@ export function WorkspacePanel({
);
return (
<>
<ResizablePanelGroup orientation="vertical">
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
<div className="flex h-full flex-col">
@@ -207,6 +222,28 @@ export function WorkspacePanel({
)}
Explain
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={handleFormat}
disabled={!sqlValue.trim()}
title="Format SQL (Shift+Alt+F)"
>
<AlignLeft className="h-3 w-3" />
Format
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={() => setSaveDialogOpen(true)}
disabled={!sqlValue.trim()}
title="Save query"
>
<Bookmark className="h-3 w-3" />
Save
</Button>
{result && result.columns.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -244,6 +281,7 @@ export function WorkspacePanel({
value={sqlValue}
onChange={handleChange}
onExecute={handleExecute}
onFormat={handleFormat}
schema={completionSchema}
/>
</div>
@@ -288,5 +326,13 @@ export function WorkspacePanel({
)}
</ResizablePanel>
</ResizablePanelGroup>
<SaveQueryDialog
open={saveDialogOpen}
onOpenChange={setSaveDialogOpen}
sql={sqlValue}
connectionId={connectionId}
/>
</>
);
}

View File

@@ -10,6 +10,9 @@ import {
getTablePrivileges,
grantRevoke,
manageRoleMembership,
listSessions,
cancelQuery,
terminateBackend,
} from "@/lib/tauri";
import type {
CreateDatabaseParams,
@@ -149,6 +152,39 @@ export function useGrantRevoke() {
});
}
// Sessions
export function useSessions(connectionId: string | null) {
return useQuery({
queryKey: ["sessions", connectionId],
queryFn: () => listSessions(connectionId!),
enabled: !!connectionId,
refetchInterval: 5000,
});
}
export function useCancelQuery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ connectionId, pid }: { connectionId: string; pid: number }) =>
cancelQuery(connectionId, pid),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
});
}
export function useTerminateBackend() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ connectionId, pid }: { connectionId: string; pid: number }) =>
terminateBackend(connectionId, pid),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
});
}
export function useManageRoleMembership() {
const queryClient = useQueryClient();
return useMutation({

View File

@@ -0,0 +1,34 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
listSavedQueries,
saveQuery,
deleteSavedQuery,
} from "@/lib/tauri";
import type { SavedQuery } from "@/types";
export function useSavedQueries(search?: string) {
return useQuery({
queryKey: ["saved-queries", search],
queryFn: () => listSavedQueries({ search }),
});
}
export function useSaveQuery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (query: SavedQuery) => saveQuery(query),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["saved-queries"] });
},
});
}
export function useDeleteSavedQuery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteSavedQuery(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["saved-queries"] });
},
});
}

View File

@@ -7,6 +7,7 @@ import {
listFunctions,
listSequences,
switchDatabase,
getColumnDetails,
} from "@/lib/tauri";
import type { ConnectionConfig } from "@/types";
@@ -72,3 +73,11 @@ export function useSequences(connectionId: string | null, schema: string) {
enabled: !!connectionId && !!schema,
});
}
export function useColumnDetails(connectionId: string | null, schema: string | null, table: string | null) {
return useQuery({
queryKey: ["column-details", connectionId, schema, table],
queryFn: () => getColumnDetails(connectionId!, schema!, table!),
enabled: !!connectionId && !!schema && !!table,
});
}

View File

@@ -4,10 +4,13 @@ import type {
QueryResult,
PaginatedQueryResult,
SchemaObject,
ColumnDetail,
ColumnInfo,
ConstraintInfo,
IndexInfo,
HistoryEntry,
SavedQuery,
SessionInfo,
DatabaseInfo,
CreateDatabaseParams,
RoleInfo,
@@ -79,6 +82,12 @@ export const getTableColumns = (
table: string
) => invoke<ColumnInfo[]>("get_table_columns", { connectionId, schema, table });
export const getColumnDetails = (
connectionId: string,
schema: string,
table: string
) => invoke<ColumnDetail[]>("get_column_details", { connectionId, schema, table });
export const getTableConstraints = (
connectionId: string,
schema: string,
@@ -151,6 +160,16 @@ export const getHistory = (params?: {
export const clearHistory = () => invoke<void>("clear_history");
// Saved Queries
export const listSavedQueries = (params?: { search?: string }) =>
invoke<SavedQuery[]>("list_saved_queries", { search: params?.search });
export const saveQuery = (query: SavedQuery) =>
invoke<void>("save_query", { query });
export const deleteSavedQuery = (id: string) =>
invoke<void>("delete_saved_query", { id });
// Completion schema
export const getCompletionSchema = (connectionId: string) =>
invoke<Record<string, Record<string, string[]>>>(
@@ -201,3 +220,13 @@ export const grantRevoke = (connectionId: string, params: GrantRevokeParams) =>
export const manageRoleMembership = (connectionId: string, params: RoleMembershipParams) =>
invoke<void>("manage_role_membership", { connectionId, params });
// Sessions
export const listSessions = (connectionId: string) =>
invoke<SessionInfo[]>("list_sessions", { connectionId });
export const cancelQuery = (connectionId: string, pid: number) =>
invoke<boolean>("cancel_query", { connectionId, pid });
export const terminateBackend = (connectionId: string, pid: number) =>
invoke<boolean>("terminate_backend", { connectionId, pid });

View File

@@ -29,6 +29,16 @@ export interface SchemaObject {
name: string;
object_type: string;
schema: string;
row_count?: number;
size_bytes?: number;
}
export interface ColumnDetail {
column_name: string;
data_type: string;
is_nullable: boolean;
column_default: string | null;
is_identity: boolean;
}
export interface ColumnInfo {
@@ -186,7 +196,27 @@ export interface RoleMembershipParams {
member_name: string;
}
export type TabType = "query" | "table" | "structure" | "roles";
export interface SessionInfo {
pid: number;
usename: string | null;
datname: string | null;
state: string | null;
query: string | null;
query_start: string | null;
wait_event_type: string | null;
wait_event: string | null;
client_addr: string | null;
}
export interface SavedQuery {
id: string;
name: string;
sql: string;
connection_id?: string;
created_at: string;
}
export type TabType = "query" | "table" | "structure" | "roles" | "sessions";
export interface Tab {
id: string;