diff --git a/src/components/export/ExportDialog.tsx b/src/components/export/ExportDialog.tsx
new file mode 100644
index 0000000..7ef2784
--- /dev/null
+++ b/src/components/export/ExportDialog.tsx
@@ -0,0 +1,113 @@
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { save } from "@tauri-apps/plugin-dialog";
+import { exportCsv, exportJson } from "@/lib/tauri";
+import { toast } from "sonner";
+import { Loader2 } from "lucide-react";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ columns: string[];
+ rows: unknown[][];
+}
+
+export function ExportDialog({ open, onOpenChange, columns, rows }: Props) {
+ const [format, setFormat] = useState<"csv" | "json">("csv");
+ const [isExporting, setIsExporting] = useState(false);
+
+ const handleExport = async () => {
+ const ext = format === "csv" ? "csv" : "json";
+ const path = await save({
+ defaultPath: `export.${ext}`,
+ filters: [
+ {
+ name: format.toUpperCase(),
+ extensions: [ext],
+ },
+ ],
+ });
+
+ if (!path) return;
+
+ setIsExporting(true);
+ try {
+ if (format === "csv") {
+ await exportCsv(path, columns, rows);
+ } else {
+ await exportJson(path, columns, rows);
+ }
+ toast.success(`Exported ${rows.length} rows to ${ext.toUpperCase()}`);
+ onOpenChange(false);
+ } catch (err) {
+ toast.error("Export failed", { description: String(err) });
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/table-viewer/PaginationControls.tsx b/src/components/table-viewer/PaginationControls.tsx
new file mode 100644
index 0000000..2ed8a6a
--- /dev/null
+++ b/src/components/table-viewer/PaginationControls.tsx
@@ -0,0 +1,105 @@
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ ChevronsLeft,
+ ChevronLeft,
+ ChevronRight,
+ ChevronsRight,
+} from "lucide-react";
+
+interface Props {
+ page: number;
+ pageSize: number;
+ totalRows: number;
+ onPageChange: (page: number) => void;
+ onPageSizeChange: (size: number) => void;
+}
+
+export function PaginationControls({
+ page,
+ pageSize,
+ totalRows,
+ onPageChange,
+ onPageSizeChange,
+}: Props) {
+ const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
+ const from = (page - 1) * pageSize + 1;
+ const to = Math.min(page * pageSize, totalRows);
+
+ return (
+
+
+ Showing {totalRows > 0 ? from.toLocaleString() : 0}-
+ {to.toLocaleString()} of {totalRows.toLocaleString()}
+
+
+
+
+
+
+
+
+
+
+ {page} / {totalPages}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/table-viewer/TableDataView.tsx b/src/components/table-viewer/TableDataView.tsx
new file mode 100644
index 0000000..104f7d5
--- /dev/null
+++ b/src/components/table-viewer/TableDataView.tsx
@@ -0,0 +1,204 @@
+import { useState, useCallback, useMemo } from "react";
+import { useTableData } from "@/hooks/use-table-data";
+import { ResultsTable } from "@/components/results/ResultsTable";
+import { PaginationControls } from "./PaginationControls";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { updateRow as updateRowApi } from "@/lib/tauri";
+import { getTableColumns } from "@/lib/tauri";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Save, RotateCcw, Filter, Loader2 } from "lucide-react";
+
+interface Props {
+ connectionId: string;
+ schema: string;
+ table: string;
+}
+
+export function TableDataView({ connectionId, schema, table }: Props) {
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(50);
+ const [sortColumn, _setSortColumn] = useState();
+ const [sortDirection, _setSortDirection] = useState();
+ const [filter, setFilter] = useState("");
+ const [appliedFilter, setAppliedFilter] = useState();
+ const [pendingChanges, setPendingChanges] = useState<
+ Map
+ >(new Map());
+ const [isSaving, setIsSaving] = useState(false);
+ const queryClient = useQueryClient();
+
+ const { data, isLoading, error } = useTableData({
+ connectionId,
+ schema,
+ table,
+ page,
+ pageSize,
+ sortColumn,
+ sortDirection,
+ filter: appliedFilter,
+ });
+
+ const { data: columnsInfo } = useQuery({
+ queryKey: ["table-columns", connectionId, schema, table],
+ queryFn: () => getTableColumns(connectionId, schema, table),
+ });
+
+ const pkColumns = useMemo(
+ () => columnsInfo?.filter((c) => c.is_primary_key).map((c) => c.name) ?? [],
+ [columnsInfo]
+ );
+
+ const highlightedCells = useMemo(
+ () => new Set(pendingChanges.keys()),
+ [pendingChanges]
+ );
+
+ const handleCellDoubleClick = useCallback(
+ (rowIndex: number, colIndex: number, value: unknown) => {
+ const key = `${rowIndex}:${colIndex}`;
+ const currentValue = pendingChanges.get(key)?.value ?? value;
+ const newVal = prompt("Edit value (leave empty and click NULL for null):",
+ currentValue === null ? "" : String(currentValue));
+ if (newVal !== null) {
+ setPendingChanges((prev) => {
+ const next = new Map(prev);
+ next.set(key, { rowIndex, colIndex, value: newVal === "" ? null : newVal });
+ return next;
+ });
+ }
+ },
+ [pendingChanges]
+ );
+
+ const handleCommit = async () => {
+ if (!data || pkColumns.length === 0) {
+ toast.error("Cannot save: no primary key detected");
+ return;
+ }
+ setIsSaving(true);
+ try {
+ for (const [_key, change] of pendingChanges) {
+ const row = data.rows[change.rowIndex];
+ const pkValues = pkColumns.map((pkCol) => {
+ const idx = data.columns.indexOf(pkCol);
+ return row[idx];
+ });
+ const colName = data.columns[change.colIndex];
+
+ await updateRowApi({
+ connectionId,
+ schema,
+ table,
+ pkColumns,
+ pkValues: pkValues as unknown[],
+ column: colName,
+ value: change.value,
+ });
+ }
+ setPendingChanges(new Map());
+ queryClient.invalidateQueries({
+ queryKey: ["table-data", connectionId],
+ });
+ toast.success(`${pendingChanges.size} change(s) saved`);
+ } catch (err) {
+ toast.error("Save failed", { description: String(err) });
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleRollback = () => {
+ setPendingChanges(new Map());
+ };
+
+ const handleApplyFilter = () => {
+ setAppliedFilter(filter || undefined);
+ setPage(1);
+ };
+
+ return (
+
+
+
+ setFilter(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleApplyFilter()}
+ />
+
+ {pendingChanges.size > 0 && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {isLoading && !data ? (
+
+
+ Loading...
+
+ ) : error ? (
+
+ {String(error)}
+
+ ) : data ? (
+
+ ) : null}
+
+
+ {data && (
+
{
+ setPageSize(size);
+ setPage(1);
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/table-viewer/TableStructure.tsx b/src/components/table-viewer/TableStructure.tsx
new file mode 100644
index 0000000..dc7676e
--- /dev/null
+++ b/src/components/table-viewer/TableStructure.tsx
@@ -0,0 +1,168 @@
+import { useQuery } from "@tanstack/react-query";
+import {
+ getTableColumns,
+ getTableConstraints,
+ getTableIndexes,
+} from "@/lib/tauri";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+interface Props {
+ connectionId: string;
+ schema: string;
+ table: string;
+}
+
+export function TableStructure({ connectionId, schema, table }: Props) {
+ const { data: columns } = useQuery({
+ queryKey: ["table-columns", connectionId, schema, table],
+ queryFn: () => getTableColumns(connectionId, schema, table),
+ });
+
+ const { data: constraints } = useQuery({
+ queryKey: ["table-constraints", connectionId, schema, table],
+ queryFn: () => getTableConstraints(connectionId, schema, table),
+ });
+
+ const { data: indexes } = useQuery({
+ queryKey: ["table-indexes", connectionId, schema, table],
+ queryFn: () => getTableIndexes(connectionId, schema, table),
+ });
+
+ return (
+
+
+
+ Columns{columns ? ` (${columns.length})` : ""}
+
+
+ Constraints{constraints ? ` (${constraints.length})` : ""}
+
+
+ Indexes{indexes ? ` (${indexes.length})` : ""}
+
+
+
+
+
+
+
+
+ #
+ Name
+ Type
+ Nullable
+ Default
+ Key
+
+
+
+ {columns?.map((col) => (
+
+
+ {col.ordinal_position}
+
+
+ {col.name}
+
+
+ {col.data_type}
+ {col.character_maximum_length
+ ? `(${col.character_maximum_length})`
+ : ""}
+
+
+ {col.is_nullable ? "YES" : "NO"}
+
+
+ {col.column_default ?? "—"}
+
+
+ {col.is_primary_key && (
+
+ PK
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ Name
+ Type
+ Columns
+
+
+
+ {constraints?.map((c) => (
+
+
+ {c.name}
+
+
+
+ {c.constraint_type}
+
+
+
+ {c.columns.join(", ")}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ Name
+ Definition
+ Unique
+ Primary
+
+
+
+ {indexes?.map((idx) => (
+
+
+ {idx.name}
+
+
+ {idx.definition}
+
+
+ {idx.is_unique ? "YES" : "NO"}
+
+
+ {idx.is_primary ? "YES" : "NO"}
+
+
+ ))}
+
+
+
+
+
+ );
+}