-
-
setEditModalOpen(true)}
- connectionId={connectionId}
- searchQuery={searchQuery}
- onSearchChange={setSearchQuery}
- />
-
-
- {/* panel resize handle */}
-
setTablePanelWidth(280)}
- />
-
-
- {activeTab?.data && (
-
- setHiddenColumns((prev) => {
- const next = new Set(prev);
- if (next.has(col)) next.delete(col); else next.add(col);
- return next;
- })
- }
- onRefresh={handleRefresh}
- filterRules={filterRules}
- onFilterChange={setFilterRules}
- sortRules={sortRules}
- onSortChange={setSortRules}
- defaultRefreshRate={settings?.table_refresh_rate ?? 0}
- selectedCount={selectedRows.size}
- selectedRows={processedRows.filter((_, i) => selectedRows.has(i))}
- onClearSelection={() => setSelectedRows(new Set())}
+ const getColType = (name: string) => {
+ const col = cols.find(
+ (c) => c.name.toLowerCase() === name.toLowerCase(),
+ );
+ return col?.data_type.toLowerCase() ?? "";
+ };
+ const isNumeric = (name: string) => {
+ const t = getColType(name);
+ return [
+ "integer",
+ "int",
+ "int2",
+ "int4",
+ "int8",
+ "smallint",
+ "bigint",
+ "serial",
+ "bigserial",
+ "smallserial",
+ "tinyint",
+ "mediumint",
+ "numeric",
+ "decimal",
+ "real",
+ "float",
+ "float4",
+ "float8",
+ "double precision",
+ "double",
+ "number",
+ ].includes(t);
+ };
+ const isTimestamp = (name: string) => {
+ const t = getColType(name);
+ return [
+ "timestamp",
+ "timestamptz",
+ "timestamp without time zone",
+ "timestamp with time zone",
+ "date",
+ "datetime",
+ "datetime2",
+ "smalldatetime",
+ ].some((pt) => t.includes(pt));
+ };
+
+ // Find the first column name that exists and passes type checks
+ const findCol = (
+ candidates: string[],
+ numericOnly = false,
+ ): string | undefined => {
+ for (const cand of candidates) {
+ const match = cols.find(
+ (c) => c.name.toLowerCase() === cand.toLowerCase(),
+ );
+ if (!match) continue;
+ if (numericOnly && !isNumeric(match.name)) continue;
+ return match.name;
+ }
+ return undefined;
+ };
+ const findBySuffix = (
+ suffixes: string[],
+ numericOnly = false,
+ ): string | undefined => {
+ for (const c of cols) {
+ const name = c.name.toLowerCase();
+ if (suffixes.some((s) => name.endsWith(s))) {
+ if (numericOnly && !isNumeric(c.name)) continue;
+ return c.name;
+ }
+ }
+ return undefined;
+ };
+ const findByPrefix = (
+ prefixes: string[],
+ numericOnly = false,
+ ): string | undefined => {
+ for (const c of cols) {
+ const name = c.name.toLowerCase();
+ if (prefixes.some((p) => name.startsWith(p))) {
+ if (numericOnly && !isNumeric(c.name)) continue;
+ return c.name;
+ }
+ }
+ return undefined;
+ };
+
+ // Priority-ordered rules: each returns [columnName | undefined, order]
+ const rules: Array<() => [string | undefined, "asc" | "desc"]> = [
+ // Tier 1: Explicit recency columns
+ () => [
+ findCol([
+ "updated_at",
+ "modified_at",
+ "changed_at",
+ "altered_at",
+ "revised_at",
+ ]),
+ "desc",
+ ],
+ () => [
+ findCol([
+ "created_at",
+ "inserted_at",
+ "added_at",
+ "published_at",
+ "posted_at",
+ "registered_at",
+ ]),
+ "desc",
+ ],
+ () => [
+ findCol([
+ "deleted_at",
+ "removed_at",
+ "expired_at",
+ "archived_at",
+ ]),
+ "desc",
+ ],
+ // Tier 2: Generic date/timestamp columns (DESC = newest)
+ () => {
+ const col = cols.find((c) => isTimestamp(c.name));
+ return col ? [col.name, "desc"] : [undefined, "desc"];
+ },
+ // Tier 3: Any *_at suffix (covers updated_at, created_at, etc. in any casing)
+ () => [findBySuffix(["_at"]), "desc"],
+ // Tier 4: Any *_on suffix (e.g. action_on, performed_on)
+ () => [findBySuffix(["_on"]), "desc"],
+ // Tier 5: last_* prefix (e.g. last_login, last_seen, last_modified)
+ () => [findByPrefix(["last_"]), "desc"],
+ // Tier 6: Numeric ID (DESC = highest/newest)
+ () => [findCol(["id", "uid", "pk"], true), "desc"],
+ // Tier 7: Any *_id suffix (numeric FKs usually increment)
+ () => [findBySuffix(["_id"], true), "desc"],
+ // Tier 8: Sequence/order columns (ASC = natural order)
+ () => [
+ findCol(
+ [
+ "seq",
+ "sequence",
+ "ordinal",
+ "sort",
+ "sort_order",
+ "sortorder",
+ "position",
+ "pos",
+ "display_order",
+ ],
+ true,
+ ),
+ "asc",
+ ],
+ // Tier 9: Rank/priority (ASC if lower = higher priority, DESC if higher = more)
+ () => [
+ findCol(
+ [
+ "rank",
+ "ranking",
+ "priority",
+ "weight",
+ "score",
+ "rating",
+ ],
+ true,
+ ),
+ "desc",
+ ],
+ // Tier 10: Version/revision tracking (DESC = latest)
+ () => [
+ findCol(
+ ["version", "revision", "rev", "build", "release"],
+ true,
+ ),
+ "desc",
+ ],
+ // Tier 11: Count/quantity (DESC = most)
+ () => [
+ findCol(
+ [
+ "count",
+ "total",
+ "amount",
+ "quantity",
+ "qty",
+ "num",
+ "number",
+ "no",
+ ],
+ true,
+ ),
+ "desc",
+ ],
+ ];
+
+ for (const rule of rules) {
+ const [colName, order] = rule();
+ if (colName) {
+ setSmartSortApplied(activeTab.id);
+ setSortRules(activeTab.id, [
+ { id: crypto.randomUUID(), column: colName, order },
+ ]);
+ return;
+ }
+ }
+ }, [activeTab]);
+
+ // Sync tab columnFilter (set by FK popover) into the toolbar filterRules
+ useEffect(() => {
+ if (!activeTab?.columnFilter) return;
+ const { column, value } = activeTab.columnFilter;
+ const currentRules = activeTab.filterRules ?? [];
+ const exists = currentRules.some(
+ (r) => r.column === column && r.value === value,
+ );
+ if (exists) return;
+ setFilterRules(activeTab.id, [
+ ...currentRules,
+ {
+ id: crypto.randomUUID(),
+ column,
+ operator: "contains" as const,
+ value,
+ },
+ ]);
+ }, [activeTab?.columnFilter, activeTab?.id, activeTab?.filterRules, setFilterRules]);
+
+ // When the FK filter rule is removed from the toolbar, clear the tab's columnFilter
+ useEffect(() => {
+ if (!activeTab?.columnFilter) return;
+ const { column, value } = activeTab.columnFilter;
+ const stillExists = filterRules.some(
+ (r) => r.column === column && r.value === value,
+ );
+ if (!stillExists) {
+ clearColumnFilter(activeTab.id);
+ }
+ }, [filterRules, activeTab, clearColumnFilter]);
+
+ // Refresh: clear data so auto-fetch effect re-fetches
+ const handleRefresh = useCallback(() => {
+ const tabId = useDbViewerStore.getState().activeTabId;
+ if (!tabId) return;
+ useDbViewerStore.setState((s) => ({
+ tabs: s.tabs.map((t) =>
+ t.id === tabId ? { ...t, loading: true, error: null } : t,
+ ),
+ }));
+ }, []);
+
+ const rawRows = activeTab?.data?.rows ?? [];
+ const columns = activeTab?.data?.columns ?? [];
+ // Data is already filtered and sorted server-side; no client-side transform needed.
+ const processedRows = rawRows;
+
+ const onPanelResizeStart = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ panelResizeRef.current = {
+ startX: e.clientX,
+ startW: tablePanelWidth,
+ };
+ const onMove = (ev: MouseEvent) => {
+ if (!panelResizeRef.current) return;
+ const w = Math.max(
+ 180,
+ Math.min(
+ 600,
+ panelResizeRef.current.startW +
+ (ev.clientX - panelResizeRef.current.startX),
+ ),
+ );
+ setTablePanelWidth(w);
+ };
+ const onUp = () => {
+ panelResizeRef.current = null;
+ document.removeEventListener("mousemove", onMove);
+ document.removeEventListener("mouseup", onUp);
+ };
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ },
+ [tablePanelWidth],
+ );
+
+ const handleNavigate = useCallback(
+ (view: string) => {
+ if (view === "home") onHome();
+ else if (view === "settings") onSettings();
+ else setCurrentView(view);
+ },
+ [onHome, onSettings],
+ );
+
+ const activeSchema = activeTab?.schema ?? "";
+ const activeTable = activeTab?.table ?? "";
+
+ return (
+
+
+
- )}
-
-
-
+
+ {connectionError && connectionError !== dismissedError && (
+
{
+ setDismissedError(null);
+ connect();
+ }}
+ onDismiss={() => setDismissedError(connectionError)}
+ />
+ )}
+ {currentView === "db-viewer" ? (
+
+
+
setEditModalOpen(true)}
+ connectionId={connectionId}
+ searchQuery={searchQuery}
+ onSearchChange={setSearchQuery}
+ />
+
+
+ {/* panel resize handle */}
+
setTablePanelWidth(280)}
+ />
+
+
+ {activeTab?.data && (
+
+ toggleHiddenColumn(activeTab!.id, col)
+ }
+ onRefresh={handleRefresh}
+ filterRules={filterRules}
+ onFilterChange={(rules) =>
+ setFilterRules(activeTab!.id, rules)
+ }
+ sortRules={sortRules}
+ onSortChange={(rules) =>
+ setSortRules(activeTab!.id, rules)
+ }
+ defaultRefreshRate={
+ settings?.table_refresh_rate ?? 0
+ }
+ selectedCount={selectedRows.size}
+ selectedRows={processedRows.filter(
+ (_, i) => selectedRows.has(i),
+ )}
+ onClearSelection={() =>
+ setSelectedRows(new Set())
+ }
+ />
+ )}
+
+ {
+ setSelectedRows((prev) => {
+ const next = new Set(prev);
+ if (next.has(rowIndex))
+ next.delete(rowIndex);
+ else next.add(rowIndex);
+ return next;
+ });
+ }}
+ onToggleAll={() => {
+ setSelectedRows((prev) => {
+ if (
+ prev.size ===
+ processedRows.length &&
+ processedRows.length > 0
+ ) {
+ return new Set();
+ }
+ return new Set(
+ processedRows.map(
+ (_, i) => i,
+ ),
+ );
+ });
+ }}
+ />
+
+
+
+ ) : currentView === "functions" ? (
+
+ ) : currentView === "triggers" ? (
+
+ ) : currentView === "sequences" ? (
+
+ ) : currentView === "enums" ? (
+
+ ) : currentView === "extensions" ? (
+
+ ) : currentView === "backup" ? (
+
+ ) : currentView === "restore" ? (
+
+ ) : currentView === "sync" ? (
+
+ ) : null}
+ {currentView === "db-viewer" &&
}
+
+ {currentConnection && (
+ setEditModalOpen(false)}
+ onSaved={() => {}}
+ />
+ )}
-
-
-
- {currentConnection && (
-
setEditModalOpen(false)}
- onSaved={() => {}}
- />
- )}
-
-
- );
-}
\ No newline at end of file
+
+ );
+}
diff --git a/src/components/db-viewer/DbViewerSidebar.tsx b/src/components/db-viewer/DbViewerSidebar.tsx
index ecfbfcc..2434957 100644
--- a/src/components/db-viewer/DbViewerSidebar.tsx
+++ b/src/components/db-viewer/DbViewerSidebar.tsx
@@ -1,57 +1,102 @@
-import { Database, Grid2x2, FunctionSquare, GitBranch, Home, Settings } from "lucide-react";
+import {
+ ArrowLeftRight,
+ Database,
+ Download,
+ FunctionSquare,
+ GitBranch,
+ Grid2x2,
+ Home,
+ ListOrdered,
+ Puzzle,
+ Settings,
+ Tag,
+ Upload,
+} from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
export interface DbViewerSidebarProps {
- currentView: string;
- onNavigate: (view: string) => void;
+ currentView: string;
+ onNavigate: (view: string) => void;
}
interface NavItem {
- id: string;
- label: string;
- icon: React.ReactNode;
- stub?: boolean;
+ id: string;
+ label: string;
+ icon: React.ReactNode;
+ stub?: boolean;
}
-export function DbViewerSidebar({ currentView, onNavigate }: DbViewerSidebarProps) {
- const topItems: NavItem[] = [
- { id: "db-viewer", label: "Explorer", icon:
},
- { id: "schema-visualizer", label: "Schema Visualizer coming soon", icon:
, stub: true },
- { id: "functions", label: "Functions coming soon", icon:
, stub: true },
- { id: "triggers", label: "Triggers coming soon", icon:
, stub: true },
- ];
+export function DbViewerSidebar({
+ currentView,
+ onNavigate,
+}: DbViewerSidebarProps) {
+ const topItems: NavItem[] = [
+ { id: "db-viewer", label: "Explorer", icon:
},
+ {
+ id: "schema-visualizer",
+ label: "Schema Visualizer coming soon",
+ icon:
,
+ stub: true,
+ },
+ {
+ id: "functions",
+ label: "Functions",
+ icon:
,
+ },
+ { id: "triggers", label: "Triggers", icon:
},
+ {
+ id: "sequences",
+ label: "Sequences",
+ icon:
,
+ },
+ { id: "enums", label: "Enums", icon:
},
+ { id: "extensions", label: "Extensions", icon:
},
+ { id: "backup", label: "Backup", icon:
},
+ { id: "restore", label: "Restore", icon:
},
+ {
+ id: "sync",
+ label: "DB Sync",
+ icon:
,
+ },
+ ];
- const bottomItems: NavItem[] = [
- { id: "home", label: "Home", icon:
},
- { id: "settings", label: "Settings", icon:
},
- ];
+ const bottomItems: NavItem[] = [
+ { id: "home", label: "Home", icon:
},
+ { id: "settings", label: "Settings", icon:
},
+ ];
- function renderItem(item: NavItem) {
- const isActive = currentView === item.id;
- const baseClass = "w-10 h-10 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
- const activeClass = "text-accent";
- const inactiveClass = "text-text-muted hover:text-text hover:bg-surface-raised";
- const stubClass = "opacity-40 cursor-not-allowed";
+ function renderItem(item: NavItem) {
+ const isActive = currentView === item.id;
+ const baseClass =
+ "w-10 h-10 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
+ const activeClass = "text-accent";
+ const inactiveClass =
+ "text-text-muted hover:text-text hover:bg-surface-raised";
+ const stubClass = "opacity-40 cursor-not-allowed";
+
+ return (
+
+
+
+ );
+ }
return (
-
-
-
+
+
+ {topItems.map(renderItem)}
+
+
+ {bottomItems.map(renderItem)}
+
+
);
- }
-
- return (
-
-
{topItems.map(renderItem)}
-
{bottomItems.map(renderItem)}
-
- );
-}
\ No newline at end of file
+}
diff --git a/src/components/db-viewer/DbViewerToolbar.tsx b/src/components/db-viewer/DbViewerToolbar.tsx
index 14454a2..d34b9ad 100644
--- a/src/components/db-viewer/DbViewerToolbar.tsx
+++ b/src/components/db-viewer/DbViewerToolbar.tsx
@@ -1,4 +1,12 @@
-import { RefreshCw, Plus, Search, Pencil, Check, AlertCircle, X } from "lucide-react";
+import {
+ RefreshCw,
+ Plus,
+ Search,
+ Pencil,
+ Check,
+ AlertCircle,
+ X,
+} from "lucide-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { SelectDropdown } from "../ui/SelectDropdown";
import { Tooltip } from "../ui/Tooltip";
@@ -6,190 +14,213 @@ import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
export function DbViewerToolbar({
- databases,
- currentDatabase,
- setCurrentDatabase,
- schemas,
- currentSchema,
- setCurrentSchema,
- onEdit,
- connectionId,
- searchQuery,
- onSearchChange,
+ databases,
+ currentDatabase,
+ setCurrentDatabase,
+ schemas,
+ currentSchema,
+ setCurrentSchema,
+ onEdit,
+ connectionId,
+ searchQuery,
+ onSearchChange,
}: {
- databases: string[];
- currentDatabase: string | null;
- setCurrentDatabase: (db: string | null) => void;
- schemas: string[];
- currentSchema: string | null;
- setCurrentSchema: (schema: string | null) => void;
- onEdit?: () => void;
- connectionId?: string;
- searchQuery: string;
- onSearchChange: (q: string) => void;
+ databases: string[];
+ currentDatabase: string | null;
+ setCurrentDatabase: (db: string | null) => void;
+ schemas: string[];
+ currentSchema: string | null;
+ setCurrentSchema: (schema: string | null) => void;
+ onEdit?: () => void;
+ connectionId?: string;
+ searchQuery: string;
+ onSearchChange: (q: string) => void;
}) {
- const [searchOpen, setSearchOpen] = useState(false);
- const [refreshing, setRefreshing] = useState(false);
- const [result, setResult] = useState<'idle' | 'success' | 'error'>('idle');
- const resultTimer = useRef
| null>(null);
- const searchInputRef = useRef(null);
- const searchContainerRef = useRef(null);
- const populate = useDbViewerStore((s) => s.populate);
+ const [searchOpen, setSearchOpen] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+ const [result, setResult] = useState<"idle" | "success" | "error">("idle");
+ const resultTimer = useRef | null>(null);
+ const searchInputRef = useRef(null);
+ const searchContainerRef = useRef(null);
+ const populate = useDbViewerStore((s) => s.populate);
- // Focus input when search opens
- useEffect(() => {
- if (searchOpen && searchInputRef.current) {
- searchInputRef.current.focus();
- }
- }, [searchOpen]);
+ // Focus input when search opens
+ useEffect(() => {
+ if (searchOpen && searchInputRef.current) {
+ searchInputRef.current.focus();
+ }
+ }, [searchOpen]);
- // Auto-hide on blur when empty
- const handleSearchBlur = useCallback(() => {
- // Small delay to allow clicks on clear button / search icon
- setTimeout(() => {
- if (!searchQuery.trim()) {
- setSearchOpen(false);
- }
- }, 150);
- }, [searchQuery]);
+ // Auto-hide on blur when empty
+ const handleSearchBlur = useCallback(() => {
+ // Small delay to allow clicks on clear button / search icon
+ setTimeout(() => {
+ if (!searchQuery.trim()) {
+ setSearchOpen(false);
+ }
+ }, 150);
+ }, [searchQuery]);
- const toggleSearch = useCallback(() => {
- setSearchOpen((prev) => {
- const next = !prev;
- if (!next) onSearchChange(""); // clear when closing
- return next;
- });
- }, [onSearchChange]);
+ const toggleSearch = useCallback(() => {
+ setSearchOpen((prev) => {
+ const next = !prev;
+ if (!next) onSearchChange(""); // clear when closing
+ return next;
+ });
+ }, [onSearchChange]);
- // Cleanup result timer on unmount
- useEffect(() => {
- return () => { if (resultTimer.current) clearTimeout(resultTimer.current); };
- }, []);
+ // Cleanup result timer on unmount
+ useEffect(() => {
+ return () => {
+ if (resultTimer.current) clearTimeout(resultTimer.current);
+ };
+ }, []);
- const handleRefresh = useCallback(async () => {
- if (!connectionId || refreshing) return;
- setRefreshing(true);
- setResult('idle');
- try {
- const dbs = await cmd.getDatabases(connectionId);
- const scs = await cmd.getSchemas(connectionId);
- const tbls = await cmd.getTables(connectionId);
- populate(dbs, scs, tbls);
- setResult('success');
- } catch {
- setResult('error');
- } finally {
- setRefreshing(false);
- resultTimer.current = setTimeout(() => setResult('idle'), 1500);
- }
- }, [connectionId, refreshing, populate]);
+ const handleRefresh = useCallback(async () => {
+ if (!connectionId || refreshing) return;
+ setRefreshing(true);
+ setResult("idle");
+ try {
+ const dbs = await cmd.getDatabases(connectionId);
+ const scs = await cmd.getSchemas(connectionId);
+ const tbls = await cmd.getTables(connectionId);
+ populate(dbs, scs, tbls);
+ setResult("success");
+ } catch {
+ setResult("error");
+ } finally {
+ setRefreshing(false);
+ resultTimer.current = setTimeout(() => setResult("idle"), 1500);
+ }
+ }, [connectionId, refreshing, populate]);
- return (
-
-
-
Tables
-
- {onEdit && (
-
-
-
- )}
-
-
-
- {/* Search input */}
-
-
-
- onSearchChange(e.target.value)}
- onBlur={handleSearchBlur}
- placeholder="Filter tables…"
- className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
- />
- {searchQuery && (
- onSearchChange("")}
- className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
- >
-
-
- )}
-
-
- {(databases.length > 1 || schemas.length > 1) && (
-
- {databases.length > 1 && (
- ({ value: d, label: d }))}
- placeholder="Select database"
- aria-label="Select database"
- variant="ghost"
- />
- )}
- {databases.length > 1 && schemas.length > 1 && (
- |
- )}
- {schemas.length > 1 && (
- ({ value: s, label: s }))}
- placeholder="Select schema"
- aria-label="Select schema"
- variant="ghost"
- />
- )}
-
- )}
-
- );
-}
\ No newline at end of file
+ );
+}
diff --git a/src/components/db-viewer/ObjectExplorerPage.tsx b/src/components/db-viewer/ObjectExplorerPage.tsx
new file mode 100644
index 0000000..f8cca1e
--- /dev/null
+++ b/src/components/db-viewer/ObjectExplorerPage.tsx
@@ -0,0 +1,1163 @@
+import { useEffect, useState, useMemo, useCallback, useRef } from "react";
+import {
+ ChevronRight,
+ FunctionSquare,
+ GitBranch,
+ ListOrdered,
+ Tag,
+ Puzzle,
+ Search,
+ X,
+ RefreshCw,
+} from "lucide-react";
+import { useDbViewerStore } from "../../stores/dbViewerStore";
+import { SelectDropdown } from "../ui/SelectDropdown";
+import * as cmd from "../../lib/commands";
+import type {
+ FunctionInfo,
+ TriggerInfo,
+ SequenceInfo,
+ EnumInfo,
+ ExtensionInfo,
+} from "../../lib/types";
+
+export type ObjectType =
+ | "functions"
+ | "triggers"
+ | "sequences"
+ | "enums"
+ | "extensions";
+
+interface ObjectExplorerPageProps {
+ type: ObjectType;
+ connectionId: string;
+}
+
+const TYPE_LABELS: Record = {
+ functions: "Functions",
+ triggers: "Triggers",
+ sequences: "Sequences",
+ enums: "Enums",
+ extensions: "Extensions",
+};
+
+const SINGULAR_LABELS: Record = {
+ functions: "function",
+ triggers: "trigger",
+ sequences: "sequence",
+ enums: "enum",
+ extensions: "extension",
+};
+
+const ICONS: Record = {
+ functions: (
+
+ ),
+ triggers: ,
+ sequences: ,
+ enums: ,
+ extensions: ,
+};
+
+type AnyObject =
+ | FunctionInfo
+ | TriggerInfo
+ | SequenceInfo
+ | EnumInfo
+ | ExtensionInfo;
+
+/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
+function itemKey(item: AnyObject): string {
+ const name = (item as any).name as string;
+ if ("argument_types" in item && Array.isArray(item.argument_types)) {
+ return `${name}(${item.argument_types.join(",")})`;
+ }
+ return name;
+}
+
+/** Display name for the tree list. Functions show their argument signature. */
+function itemLabel(item: AnyObject): string {
+ const name = (item as any).name as string;
+ if (
+ "argument_types" in item &&
+ Array.isArray(item.argument_types) &&
+ item.argument_types.length > 0
+ ) {
+ return `${name}(${item.argument_types.join(", ")})`;
+ }
+ return name;
+}
+
+// ─── syntax highlighting for PL/pgSQL / SQL ──────────────
+
+const SQL_KEYWORDS = new Set([
+ "ADD",
+ "ALL",
+ "ALTER",
+ "AND",
+ "ANY",
+ "AS",
+ "ASC",
+ "BEGIN",
+ "BETWEEN",
+ "BY",
+ "CALL",
+ "CASCADE",
+ "CASE",
+ "CAST",
+ "CHECK",
+ "CLOSE",
+ "COLLATE",
+ "COLUMN",
+ "COMMIT",
+ "CONSTRAINT",
+ "CONTINUE",
+ "CREATE",
+ "CROSS",
+ "CURRENT",
+ "CURSOR",
+ "DECLARE",
+ "DEFAULT",
+ "DELETE",
+ "DESC",
+ "DISTINCT",
+ "DO",
+ "DROP",
+ "ELSE",
+ "ELSIF",
+ "END",
+ "EXCEPTION",
+ "EXECUTE",
+ "EXISTS",
+ "EXIT",
+ "FETCH",
+ "FOR",
+ "FOREIGN",
+ "FROM",
+ "FULL",
+ "FUNCTION",
+ "GRANT",
+ "GROUP",
+ "HAVING",
+ "IF",
+ "IN",
+ "INDEX",
+ "INNER",
+ "INSERT",
+ "INTO",
+ "IS",
+ "JOIN",
+ "KEY",
+ "LANGUAGE",
+ "LEFT",
+ "LIMIT",
+ "LOOP",
+ "NOT",
+ "NULL",
+ "OF",
+ "OFFSET",
+ "ON",
+ "OPEN",
+ "OR",
+ "ORDER",
+ "OUTER",
+ "OVER",
+ "PERFORM",
+ "PLPGSQL",
+ "PRIMARY",
+ "PROCEDURE",
+ "QUERY",
+ "RAISE",
+ "REFERENCES",
+ "REPLACE",
+ "RETURN",
+ "RETURNS",
+ "REVOKE",
+ "RIGHT",
+ "ROLLBACK",
+ "ROW",
+ "ROWS",
+ "SCHEMA",
+ "SELECT",
+ "SET",
+ "STRICT",
+ "TABLE",
+ "THEN",
+ "TO",
+ "TRIGGER",
+ "UNION",
+ "UPDATE",
+ "USING",
+ "VALUES",
+ "VIEW",
+ "WHEN",
+ "WHERE",
+ "WHILE",
+ "WITH",
+]);
+
+const SQL_TYPES = new Set([
+ "BIGINT",
+ "BIGSERIAL",
+ "BIT",
+ "BOOL",
+ "BOOLEAN",
+ "BPCHAR",
+ "BYTEA",
+ "CHAR",
+ "CHARACTER",
+ "DATE",
+ "DECIMAL",
+ "DOUBLE",
+ "FLOAT",
+ "FLOAT4",
+ "FLOAT8",
+ "INT",
+ "INT2",
+ "INT4",
+ "INT8",
+ "INTEGER",
+ "INTERVAL",
+ "JSON",
+ "JSONB",
+ "MONEY",
+ "NAME",
+ "NUMERIC",
+ "OID",
+ "REAL",
+ "SERIAL",
+ "SMALLINT",
+ "TEXT",
+ "TIME",
+ "TIMESTAMP",
+ "TIMESTAMPTZ",
+ "UUID",
+ "VARBIT",
+ "VARCHAR",
+ "VOID",
+ "XML",
+]);
+
+interface Token {
+ text: string;
+ kind:
+ | "keyword"
+ | "type"
+ | "string"
+ | "comment"
+ | "number"
+ | "operator"
+ | "plain";
+}
+
+function tokenizeLine(line: string): Token[] {
+ const tokens: Token[] = [];
+ let i = 0;
+
+ while (i < line.length) {
+ if (/\s/.test(line[i])) {
+ let ws = "";
+ while (i < line.length && /\s/.test(line[i])) {
+ ws += line[i];
+ i++;
+ }
+ tokens.push({ text: ws, kind: "plain" });
+ continue;
+ }
+ if (line[i] === "-" && line[i + 1] === "-") {
+ tokens.push({ text: line.slice(i), kind: "comment" });
+ return tokens;
+ }
+ if (line[i] === "/" && line[i + 1] === "*") {
+ const end = line.indexOf("*/", i + 2);
+ if (end !== -1) {
+ tokens.push({ text: line.slice(i, end + 2), kind: "comment" });
+ i = end + 2;
+ } else {
+ tokens.push({ text: line.slice(i), kind: "comment" });
+ return tokens;
+ }
+ continue;
+ }
+ if (line[i] === "$") {
+ let dollar = "";
+ const start = i;
+ while (i < line.length && line[i] === "$") {
+ dollar += "$";
+ i++;
+ }
+ let tag = "";
+ if (dollar.length === 1 && i < line.length && line[i] !== "$") {
+ while (i < line.length && line[i] !== "$") {
+ tag += line[i];
+ i++;
+ }
+ if (line[i] === "$") {
+ i++;
+ dollar = `$${tag}$`;
+ }
+ }
+ const endTag = dollar;
+ const endIdx = line.indexOf(endTag, i);
+ if (endIdx !== -1) {
+ tokens.push({
+ text: line.slice(start, endIdx + endTag.length),
+ kind: "string",
+ });
+ i = endIdx + endTag.length;
+ } else {
+ tokens.push({ text: line.slice(start), kind: "string" });
+ return tokens;
+ }
+ continue;
+ }
+ if (line[i] === "'") {
+ let str = "'";
+ i++;
+ while (i < line.length) {
+ if (line[i] === "'" && line[i + 1] === "'") {
+ str += "''";
+ i += 2;
+ continue;
+ }
+ if (line[i] === "'") {
+ str += "'";
+ i++;
+ break;
+ }
+ str += line[i];
+ i++;
+ }
+ tokens.push({ text: str, kind: "string" });
+ continue;
+ }
+ if (/[0-9]/.test(line[i])) {
+ let num = "";
+ while (i < line.length && /[0-9.]/.test(line[i])) {
+ num += line[i];
+ i++;
+ }
+ tokens.push({ text: num, kind: "number" });
+ continue;
+ }
+ if (/[=<>!+\-*/%&|^~@#;,.[\](){}]/.test(line[i])) {
+ let op = line[i];
+ i++;
+ if (i < line.length) {
+ const pair = op + line[i];
+ if ([":=", "=>", "<=", ">=", "<>", "||", "::"].includes(pair)) {
+ op = pair;
+ i++;
+ }
+ }
+ tokens.push({ text: op, kind: "operator" });
+ continue;
+ }
+ let word = "";
+ while (i < line.length && /[a-zA-Z_]/.test(line[i])) {
+ word += line[i];
+ i++;
+ }
+ if (word) {
+ const upper = word.toUpperCase();
+ if (SQL_KEYWORDS.has(upper)) {
+ tokens.push({ text: word, kind: "keyword" });
+ } else if (SQL_TYPES.has(upper)) {
+ tokens.push({ text: word, kind: "type" });
+ } else {
+ tokens.push({ text: word, kind: "plain" });
+ }
+ } else {
+ // Catch-all for any character not matched above (non-ASCII, symbols, etc.)
+ tokens.push({ text: line[i], kind: "plain" });
+ i++;
+ }
+ }
+ return tokens;
+}
+
+function SyntaxCode({
+ source,
+ language: _language,
+}: {
+ source: string;
+ language?: string;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const maxLines = 60;
+
+ // Memoize the tokenized output — source doesn't change while viewing
+ const { displayLines, maxLineNum, truncated, totalLines } = useMemo(() => {
+ const lines: string[] = source.split("\n");
+ const total: number = lines.length;
+ const isTruncated: boolean = !expanded && total > maxLines;
+ const display: string[] = isTruncated
+ ? lines.slice(0, maxLines)
+ : lines;
+ const maxNum: number = String(display.length).length;
+ const tokenized = display.map((line: string) => ({
+ tokens: tokenizeLine(line),
+ }));
+ return {
+ displayLines: tokenized,
+ maxLineNum: maxNum,
+ truncated: isTruncated,
+ totalLines: total,
+ };
+ }, [source, expanded]);
+
+ const TOKEN_COLORS: Record = {
+ keyword: "text-blue-400",
+ type: "text-emerald-400",
+ string: "text-amber-300",
+ comment: "text-text-subtle italic",
+ number: "text-purple-400",
+ operator: "text-text-muted",
+ plain: "text-text",
+ };
+
+ return (
+
+
+
+ {displayLines.map(
+ (entry: { tokens: Token[] }, i: number) => {
+ const { tokens } = entry;
+ const num = String(i + 1).padStart(maxLineNum, " ");
+ return (
+
+
+ {num}
+
+
+ {tokens.length === 1 &&
+ tokens[0].text.trim() === ""
+ ? "\u00A0"
+ : tokens.map((t, j) => (
+
+ {t.text}
+
+ ))}
+
+
+ );
+ },
+ )}
+
+
+ {truncated && (
+
+ setExpanded(true)}
+ className="text-xs text-accent hover:underline"
+ >
+ Show all {totalLines} lines…
+
+
+ )}
+ {expanded && totalLines > maxLines && (
+
+ setExpanded(false)}
+ className="text-xs text-accent hover:underline"
+ >
+ Collapse
+
+
+ )}
+
+ );
+}
+
+function renderDetail(type: ObjectType, item: AnyObject) {
+ switch (type) {
+ case "functions": {
+ const f = item as FunctionInfo;
+ return (
+
+
+
+ Signature
+
+
+
+
+
+ Returns
+
+
+ {f.return_type || "void"}
+
+
+
+
+ Language
+
+
+ {f.language}
+
+
+
+
+
+
+ Kind
+
+
+ {f.kind === "f" ? "Function" : "Procedure"}
+
+
+
+
+ Schema
+
+
+ {f.schema}
+
+
+
+ {f.argument_names.length > 0 && (
+ <>
+
+
+ Arguments
+
+
+ {f.argument_names.length} total
+
+
+ {f.argument_names.map((name, i) => (
+
+
+
+ {f.argument_modes?.[i] &&
+ f.argument_modes[i] !==
+ "IN" && (
+
+ {f.argument_modes[i]}
+
+ )}
+ #{i + 1}
+
+
+
+ {name}
+
+
:
+
+ {f.argument_types?.[i] || "unknown"}
+
+
+ ))}
+ >
+ )}
+ {f.source && (
+ <>
+
+
+ Source
+
+
+ {f.language}
+
+
+
+ >
+ )}
+
+ );
+ }
+ case "triggers": {
+ const t = item as TriggerInfo;
+ return (
+
+
+
+ Details
+
+
+
+
+
+ Table
+
+
+ {t.table_schema}.{t.table_name}
+
+
+
+
+ Event
+
+
+ {t.event_manipulation}
+
+
+
+
+
+
+ Timing
+
+
+ {t.action_timing} {t.action_orientation}
+
+
+
+
+ Status
+
+
+ {t.enabled === "O"
+ ? "Enabled"
+ : t.enabled === "D"
+ ? "Disabled"
+ : t.enabled}
+
+
+
+
+
+ Schema
+
+
+ {t.schema}
+
+
+ {t.action_statement && (
+ <>
+
+
+ Definition
+
+
+ SQL
+
+
+
+ >
+ )}
+
+ );
+ }
+ case "sequences": {
+ const s = item as SequenceInfo;
+ return (
+
+
+
+ Sequence Values
+
+
+
+
+
+ Current Value
+
+
+ {s.current_value}
+
+
+
+
+ Increment
+
+
+ {s.increment}
+
+
+
+
+
+
+ Start
+
+
+ {s.start_value}
+
+
+
+
+ Min / Max
+
+
+ {s.min_value} / {s.max_value}
+
+
+
+
+
+ Cycle
+
+
+ {s.cycle ? "Yes" : "No"}
+
+
+
+ );
+ }
+ case "enums": {
+ const e = item as EnumInfo;
+ return (
+
+
+
+ Details
+
+
+
+
+ Schema
+
+
+ {e.schema}
+
+
+
+
+ Values
+
+
+ {e.labels.length} labels
+
+
+ {e.labels.map((label, i) => (
+
+
+ #{i + 1}
+
+
+ {label}
+
+
+ ))}
+
+ );
+ }
+ case "extensions": {
+ const e = item as ExtensionInfo;
+ return (
+
+
+
+ Extension
+
+
+
+
+
+ Version
+
+
+ {e.version}
+
+
+
+
+ Schema
+
+
+ {e.schema}
+
+
+
+ {e.comment && (
+
+
+ Comment
+
+
+ {e.comment}
+
+
+ )}
+
+ );
+ }
+ }
+}
+
+export function ObjectExplorerPage({
+ type,
+ connectionId,
+}: ObjectExplorerPageProps) {
+ const [panelWidth, setPanelWidth] = useState(280);
+ const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
+ null,
+ );
+
+ const onPanelResizeStart = useCallback(
+ (e: React.MouseEvent) => {
+ panelResizeRef.current = { startX: e.clientX, startW: panelWidth };
+ const onMove = (ev: MouseEvent) => {
+ if (!panelResizeRef.current) return;
+ const delta = ev.clientX - panelResizeRef.current.startX;
+ const next = Math.max(
+ 180,
+ Math.min(500, panelResizeRef.current.startW + delta),
+ );
+ setPanelWidth(next);
+ };
+ const onUp = () => {
+ panelResizeRef.current = null;
+ document.removeEventListener("mousemove", onMove);
+ document.removeEventListener("mouseup", onUp);
+ };
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ },
+ [panelWidth],
+ );
+
+ const databases = useDbViewerStore((s) => s.databases);
+ const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
+ const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
+ const schemas = useDbViewerStore((s) => s.schemas);
+ const currentSchema = useDbViewerStore((s) => s.currentSchema);
+ const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
+
+ // Use store for persistence, but allow re-fetching when schema changes
+ const [items, setItems] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [searchOpen, setSearchOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const searchInputRef = useRef(null);
+ const searchContainerRef = useRef(null);
+
+ // Track last-fetched-schema so we know when to re-fetch
+ const lastSchemaRef = useRef(undefined);
+
+ // Fetch on mount and when schema changes
+ const fetch = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ let result: AnyObject[];
+ if (type === "extensions") {
+ result = await cmd.getExtensions(connectionId);
+ } else if (type === "functions") {
+ result = await cmd.getFunctions(
+ connectionId,
+ currentSchema ?? undefined,
+ );
+ } else if (type === "triggers") {
+ result = await cmd.getTriggers(
+ connectionId,
+ currentSchema ?? undefined,
+ );
+ } else if (type === "sequences") {
+ result = await cmd.getSequences(
+ connectionId,
+ currentSchema ?? undefined,
+ );
+ } else if (type === "enums") {
+ result = await cmd.getEnums(
+ connectionId,
+ currentSchema ?? undefined,
+ );
+ } else {
+ result = [];
+ }
+ setItems(result);
+ setSelectedItem(null);
+ lastSchemaRef.current = currentSchema ?? undefined;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ setItems(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [type, connectionId, currentSchema]);
+
+ useEffect(() => {
+ // Only re-fetch if schema actually changed (or first load)
+ if (lastSchemaRef.current !== (currentSchema ?? undefined)) {
+ fetch();
+ }
+ }, [currentSchema, fetch]);
+
+ // Search toggle handling
+ useEffect(() => {
+ if (searchOpen && searchInputRef.current) {
+ searchInputRef.current.focus();
+ }
+ }, [searchOpen]);
+
+ const handleSearchBlur = useCallback(() => {
+ setTimeout(() => {
+ if (!searchQuery.trim()) {
+ setSearchOpen(false);
+ }
+ }, 150);
+ }, [searchQuery]);
+
+ const toggleSearch = useCallback(() => {
+ setSearchOpen((prev) => {
+ const next = !prev;
+ if (!next) setSearchQuery("");
+ return next;
+ });
+ }, []);
+
+ const q = searchQuery.toLowerCase().trim();
+ const filtered = useMemo(() => {
+ if (!items) return [];
+ if (!q) return items;
+ return items.filter((item) => {
+ const label = itemLabel(item).toLowerCase();
+ return label.includes(q);
+ });
+ }, [items, q]);
+
+ const icon = ICONS[type];
+ const label = TYPE_LABELS[type];
+ const singular = SINGULAR_LABELS[type];
+
+ return (
+
+ {/* Left panel: toolbar + object list */}
+
+
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Search input */}
+
+
+
+ setSearchQuery(e.target.value)}
+ onBlur={handleSearchBlur}
+ placeholder={`Filter ${label.toLowerCase()}…`}
+ className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
+ />
+ {searchQuery && (
+ setSearchQuery("")}
+ className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
+ >
+
+
+ )}
+
+
+
+ {/* Database/Schema dropdowns */}
+ {(databases.length > 1 || schemas.length > 1) && (
+
+ {databases.length > 1 && (
+
+ setCurrentDatabase(v || null)
+ }
+ options={databases.map((d) => ({
+ value: d,
+ label: d,
+ }))}
+ placeholder="Select database"
+ aria-label="Select database"
+ variant="ghost"
+ />
+ )}
+ {databases.length > 1 && schemas.length > 1 && (
+ |
+ )}
+ {schemas.length > 1 && (
+
+ setCurrentSchema(v || null)
+ }
+ options={schemas.map((s) => ({
+ value: s,
+ label: s,
+ }))}
+ placeholder="Select schema"
+ aria-label="Select schema"
+ variant="ghost"
+ />
+ )}
+
+ )}
+
+
+ {/* Object list */}
+
+ {loading && (
+
+
+ Loading {label.toLowerCase()}...
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!loading && !error && filtered.length === 0 && (
+
+ {items === null
+ ? `No ${label.toLowerCase()} found`
+ : searchQuery
+ ? `No ${label.toLowerCase()} matching "${searchQuery}"`
+ : `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`}
+
+ )}
+
+ {!loading &&
+ filtered.map((item) => {
+ const name = itemLabel(item);
+ const key = itemKey(item);
+ const isSelected =
+ selectedItem !== null &&
+ itemKey(selectedItem) === itemKey(item);
+
+ return (
+
setSelectedItem(item)}
+ className={`group flex items-center gap-1 px-3 py-1 cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-accent/10 text-accent"
+ : "text-text hover:bg-surface-raised"
+ }`}
+ >
+ {icon}
+
+ {name}
+
+
+
+ );
+ })}
+
+
+
+ {/* Panel resize handle */}
+
setPanelWidth(280)}
+ />
+
+ {/* Right panel: detail view */}
+
+ {selectedItem ? (
+ <>
+ {/* Header */}
+
+
+ {itemLabel(selectedItem)}
+
+
+ {singular}
+ {"schema" in selectedItem
+ ? ` · ${(selectedItem as any).schema}`
+ : ""}
+
+
+
+ {/* Detail content */}
+
+ {renderDetail(type, selectedItem)}
+
+ >
+ ) : (
+
+
+
+ {icon}
+
+
+ Select a {singular} to view details
+
+
+ {filtered.length} {label.toLowerCase()}{" "}
+ available
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/db-viewer/ObjectTree.tsx b/src/components/db-viewer/ObjectTree.tsx
new file mode 100644
index 0000000..5bbeb4d
--- /dev/null
+++ b/src/components/db-viewer/ObjectTree.tsx
@@ -0,0 +1,329 @@
+import { useEffect, useState, useMemo } from "react";
+import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react";
+import { useDbViewerStore } from "../../stores/dbViewerStore";
+import * as cmd from "../../lib/commands";
+import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types";
+
+type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions";
+
+interface ObjectTreeProps {
+ type: ObjectType;
+ connectionId: string;
+}
+
+const TYPE_LABELS: Record
= {
+ functions: "functions",
+ triggers: "triggers",
+ sequences: "sequences",
+ enums: "enums",
+ extensions: "extensions",
+};
+
+const ICONS: Record = {
+ functions: ,
+ triggers: ,
+ sequences: ,
+ enums: ,
+ extensions: ,
+};
+
+function SourceCode({ source }: { source: string }) {
+ const [expanded, setExpanded] = useState(false);
+ const maxLen = 500;
+ const truncated = source.length > maxLen && !expanded;
+ const display = truncated ? source.slice(0, maxLen) : source;
+
+ return (
+
+
+ {display}
+ {truncated && ...}
+
+ {source.length > maxLen && (
+
{ e.stopPropagation(); setExpanded((v) => !v); }}
+ className="text-xs text-accent hover:underline mt-1"
+ >
+ {expanded ? "Show less" : "Show more"}
+
+ )}
+
+ );
+}
+
+export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
+ const currentSchema = useDbViewerStore((s) => s.currentSchema);
+ const functions = useDbViewerStore((s) => s.functions);
+ const triggers = useDbViewerStore((s) => s.triggers);
+ const sequences = useDbViewerStore((s) => s.sequences);
+ const enums = useDbViewerStore((s) => s.enums);
+ const extensions = useDbViewerStore((s) => s.extensions);
+ const setFunctions = useDbViewerStore((s) => s.setFunctions);
+ const setTriggers = useDbViewerStore((s) => s.setTriggers);
+ const setSequences = useDbViewerStore((s) => s.setSequences);
+ const setEnums = useDbViewerStore((s) => s.setEnums);
+ const setExtensions = useDbViewerStore((s) => s.setExtensions);
+
+ const [loading, setLoading] = useState(false);
+ const [expandedKeys, setExpandedKeys] = useState>(new Set());
+ const [search, setSearch] = useState("");
+
+ // Determine which store accessors to use
+ const data = useMemo(() => {
+ switch (type) {
+ case "functions": return functions;
+ case "triggers": return triggers;
+ case "sequences": return sequences;
+ case "enums": return enums;
+ case "extensions": return extensions;
+ }
+ }, [type, functions, triggers, sequences, enums, extensions]);
+
+ const setter = useMemo(() => {
+ switch (type) {
+ case "functions": return setFunctions;
+ case "triggers": return setTriggers;
+ case "sequences": return setSequences;
+ case "enums": return setEnums;
+ case "extensions": return setExtensions;
+ }
+ }, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
+
+ // Fetch on mount if not in store
+ useEffect(() => {
+ if (data !== null) return;
+ let cancelled = false;
+ setLoading(true);
+
+ const fetchData = async () => {
+ try {
+ if (type === "extensions") {
+ const result = await cmd.getExtensions(connectionId);
+ if (!cancelled) (setExtensions as (v: ExtensionInfo[]) => void)(result);
+ } else if (type === "functions") {
+ const result = await cmd.getFunctions(connectionId, currentSchema ?? undefined);
+ if (!cancelled) (setFunctions as (v: FunctionInfo[]) => void)(result);
+ } else if (type === "triggers") {
+ const result = await cmd.getTriggers(connectionId, currentSchema ?? undefined);
+ if (!cancelled) (setTriggers as (v: TriggerInfo[]) => void)(result);
+ } else if (type === "sequences") {
+ const result = await cmd.getSequences(connectionId, currentSchema ?? undefined);
+ if (!cancelled) (setSequences as (v: SequenceInfo[]) => void)(result);
+ } else if (type === "enums") {
+ const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
+ if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
+ }
+ } catch {
+ // Silently fail — store remains null, we show the empty state
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+
+ fetchData();
+ return () => { cancelled = true; };
+ }, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
+
+ const q = search.toLowerCase().trim();
+
+ const list = useMemo(() => {
+ if (!data) return [];
+ if (!q) return data;
+ return data.filter((item) => {
+ const name = "name" in item ? (item as { name: string }).name : "";
+ return name.toLowerCase().includes(q);
+ });
+ }, [data, q]);
+
+ const toggle = (key: string) => {
+ setExpandedKeys((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ };
+
+ const icon = ICONS[type];
+ const pluralLabel = TYPE_LABELS[type];
+
+ return (
+
+ {/* Search bar */}
+
+
+
+ setSearch(e.target.value)}
+ className="w-full pl-7 pr-2 py-1 text-xs bg-surface-raised border border-border rounded text-text placeholder:text-text-subtle focus:outline-none focus:border-accent/50"
+ />
+
+
+
+ {/* Content */}
+
+ {loading && (
+
+
+ Loading {pluralLabel}...
+
+ )}
+
+ {!loading && list.length === 0 && (
+
+ {data === null ? `No ${pluralLabel} found` : `No ${pluralLabel} found`}
+
+ )}
+
+ {!loading && list.map((item) => {
+ const name = "name" in item ? (item as { name: string }).name : "";
+ const key = name;
+ const isExpanded = expandedKeys.has(key);
+
+ return (
+
+
toggle(key)}
+ >
+
+ {isExpanded ? : }
+
+ {icon}
+
+ {name}
+
+
+
+ {isExpanded && (
+
+ {type === "functions" && (() => {
+ const f = item as FunctionInfo;
+ return (
+ <>
+
+ Returns: {f.return_type}
+
+
+ Language: {f.language}
+
+ {f.argument_names.length > 0 && (
+
+ Args:{" "}
+ {f.argument_names.map((a, i) => (
+
+ {f.argument_modes?.[i] && f.argument_modes[i] !== "IN" && (
+ {f.argument_modes[i]}
+ )}
+ {a} ({f.argument_types?.[i] || "unknown"})
+ {i < f.argument_names.length - 1 && ", "}
+
+ ))}
+
+ )}
+ {f.source &&
}
+ >
+ );
+ })()}
+
+ {type === "triggers" && (() => {
+ const t = item as TriggerInfo;
+ return (
+ <>
+
+ Table: {t.table_schema}.{t.table_name}
+
+
+ Event: {t.event_manipulation}
+
+
+ Timing: {t.action_timing}
+
+
+ Orientation: {t.action_orientation}
+
+
+ Enabled: {t.enabled}
+
+ {t.action_statement &&
}
+ >
+ );
+ })()}
+
+ {type === "sequences" && (() => {
+ const s = item as SequenceInfo;
+ return (
+ <>
+
+ Current: {s.current_value}
+
+
+ Increment: {s.increment}
+
+
+ Min: {s.min_value}
+
+
+ Max: {s.max_value}
+
+
+ Start: {s.start_value}
+
+
+ Cycle: {s.cycle ? "Yes" : "No"}
+
+ >
+ );
+ })()}
+
+ {type === "enums" && (() => {
+ const e = item as EnumInfo;
+ return (
+
+ {e.labels.map((label) => (
+
+ {label}
+
+ ))}
+
+ );
+ })()}
+
+ {type === "extensions" && (() => {
+ const e = item as ExtensionInfo;
+ return (
+ <>
+
+ Version: {e.version}
+
+
+ Schema: {e.schema}
+
+ {e.comment && (
+
+ Comment: {e.comment}
+
+ )}
+ >
+ );
+ })()}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/db-viewer/RestoreDialog.tsx b/src/components/db-viewer/RestoreDialog.tsx
new file mode 100644
index 0000000..7c47cdb
--- /dev/null
+++ b/src/components/db-viewer/RestoreDialog.tsx
@@ -0,0 +1,229 @@
+import { useState, useEffect, useCallback } from "react";
+import { AnimatedModal } from "../ui/AnimatedModal";
+import { Button } from "../ui/Button";
+import { BackupProgress } from "./BackupProgress";
+import { useBackupStore } from "../../stores/backupStore";
+import { useNotificationStore } from "../../stores/notificationStore";
+import { detectPgTools, pgRestore } from "../../lib/commands";
+import type { PgToolStatus } from "../../lib/types";
+
+interface RestoreDialogProps {
+ open: boolean;
+ connectionId: string;
+ onClose: () => void;
+}
+
+const PLATFORM_INSTALL_INSTRUCTIONS: Record = {
+ darwin: "brew install libpq",
+ linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
+ win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
+};
+
+function getPlatformInstructions(): string {
+ const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
+ if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
+ if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
+ if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
+ return PLATFORM_INSTALL_INSTRUCTIONS.linux;
+}
+
+export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProps) {
+ const [filePath, setFilePath] = useState("");
+ const [format, setFormat] = useState("custom");
+ const [clean, setClean] = useState(true);
+ const [schema, setSchema] = useState("");
+ const [confirmed, setConfirmed] = useState(false);
+ const [toolStatus, setToolStatus] = useState(null);
+ const [checkingTools, setCheckingTools] = useState(false);
+ const [running, setRunning] = useState(false);
+
+ const activeJobId = useBackupStore((s) => s.activeJobId);
+ const jobs = useBackupStore((s) => s.jobs);
+ const startJob = useBackupStore((s) => s.startJob);
+ const notify = useNotificationStore((s) => s.notify);
+
+ const activeJob = jobs.find((j) => j.id === activeJobId);
+
+ useEffect(() => {
+ if (!open) return;
+ setCheckingTools(true);
+ setConfirmed(false);
+ detectPgTools()
+ .then((status) => setToolStatus(status))
+ .catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
+ .finally(() => setCheckingTools(false));
+ }, [open]);
+
+ const handlePickFile = useCallback(async () => {
+ try {
+ const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
+ const picked = await openDialog({
+ multiple: false,
+ filters: [{ name: "Backup Files", extensions: ["dump", "sql", "tar", "custom", "gz"] }],
+ });
+ if (picked && typeof picked === "string") setFilePath(picked);
+ } catch {
+ // dialog not available (non-Tauri env), use manual path input
+ }
+ }, []);
+
+ const handleStartRestore = useCallback(async () => {
+ if (!filePath) {
+ notify("Please select a file path", "error");
+ return;
+ }
+ setRunning(true);
+ const jobId = `restore-${Date.now()}`;
+ startJob(jobId, "restore");
+ try {
+ await pgRestore(connectionId, {
+ format,
+ filePath,
+ clean,
+ schema: schema || undefined,
+ });
+ notify("Restore completed successfully", "success");
+ onClose();
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ notify(`Restore failed: ${parseError(msg)}`, "error");
+ } finally {
+ setRunning(false);
+ }
+ }, [filePath, format, clean, schema, connectionId, startJob, notify, onClose]);
+
+ const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
+ const canStart = filePath && confirmed && !running;
+
+ return (
+
+
+
Restore Database
+
+ {checkingTools && (
+
Checking for pg_restore...
+ )}
+
+ {toolsMissing && (
+
+
pg_restore not found
+
+ The PostgreSQL client tools are required for backup/restore operations. Install them using:
+
+
+ {getPlatformInstructions()}
+
+
+ )}
+
+ {!checkingTools && !toolsMissing && (
+
+ {/* File path */}
+
+
+
+ setFilePath(e.target.value)}
+ placeholder="/path/to/backup.dump"
+ className="flex-1 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
+ />
+
+ Browse
+
+
+
+
+ {/* Format */}
+
+
+
+
+
+ {/* Schema filter */}
+
+
+ setSchema(e.target.value)}
+ placeholder="public"
+ className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
+ />
+
+
+ {/* Clean toggle */}
+
+
+ {/* Destructive confirmation */}
+
+
+
+
+ {/* Progress */}
+ {activeJob?.status === "running" && (
+
+ )}
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+ {running ? "Restoring..." : "Start Restore"}
+
+
+
+ )}
+
+
+ );
+}
+
+function parseError(msg: string): string {
+ if (msg.includes("pg_restore:")) {
+ const [, ...rest] = msg.split("pg_restore:");
+ return rest.join(":").trim() || msg;
+ }
+ if (msg.includes("No such file or directory")) {
+ return `File not found. Check the path and try again.`;
+ }
+ if (msg.includes("Permission denied")) {
+ return `Permission denied. Check file permissions.`;
+ }
+ return msg;
+}
\ No newline at end of file
diff --git a/src/components/db-viewer/RestorePage.tsx b/src/components/db-viewer/RestorePage.tsx
new file mode 100644
index 0000000..cdd9e08
--- /dev/null
+++ b/src/components/db-viewer/RestorePage.tsx
@@ -0,0 +1,311 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+import { FileSearch, Upload } from "lucide-react";
+import { open } from "@tauri-apps/plugin-dialog";
+import { Button } from "../ui/Button";
+import { BackupProgress } from "./BackupProgress";
+import { useBackupStore } from "../../stores/backupStore";
+import { useNotificationStore } from "../../stores/notificationStore";
+import { detectPgTools, pgRestore, getSchemas } from "../../lib/commands";
+import type { PgToolStatus } from "../../lib/types";
+
+interface RestorePageProps {
+ connectionId: string;
+}
+
+const PLATFORM_INSTALL_INSTRUCTIONS: Record = {
+ darwin: "brew install libpq",
+ linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
+ win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
+};
+
+function getPlatformInstructions(): string {
+ const platform =
+ typeof navigator !== "undefined"
+ ? navigator.platform.toLowerCase()
+ : "";
+ if (platform.includes("mac") || platform.includes("darwin"))
+ return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
+ if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
+ if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
+ return PLATFORM_INSTALL_INSTRUCTIONS.linux;
+}
+
+export function RestorePage({ connectionId }: RestorePageProps) {
+ const [filePath, setFilePath] = useState("");
+ const [format, setFormat] = useState("custom");
+ const [clean, setClean] = useState(true);
+ const [schema, setSchema] = useState("");
+ const [confirmed, setConfirmed] = useState(false);
+ const [toolStatus, setToolStatus] = useState(null);
+ const [checkingTools, setCheckingTools] = useState(true);
+ const [availableSchemas, setAvailableSchemas] = useState([]);
+
+ const activeJobId = useBackupStore((s) => s.activeJobId);
+ const jobs = useBackupStore((s) => s.jobs);
+ const startJob = useBackupStore((s) => s.startJob);
+ const notify = useNotificationStore((s) => s.notify);
+
+ const activeJob = jobs.find((j) => j.id === activeJobId);
+ const isRunning = activeJob?.status === "running";
+ const pendingJobRef = useRef(null);
+
+ useEffect(() => {
+ if (!pendingJobRef.current || !activeJob) return;
+ if (activeJob.id !== pendingJobRef.current) return;
+
+ if (activeJob.status === "completed") {
+ notify("Restore completed successfully", "success");
+ pendingJobRef.current = null;
+ } else if (activeJob.status === "failed") {
+ notify(
+ `Restore failed: ${activeJob.error_message || "Unknown error"}`,
+ "error",
+ );
+ pendingJobRef.current = null;
+ }
+ }, [activeJob, notify]);
+
+ useEffect(() => {
+ setCheckingTools(true);
+ setConfirmed(false);
+ detectPgTools()
+ .then((status) => setToolStatus(status))
+ .catch(() =>
+ setToolStatus({
+ pg_dump_found: false,
+ pg_restore_found: false,
+ pg_dump_version: null,
+ pg_restore_version: null,
+ }),
+ )
+ .finally(() => setCheckingTools(false));
+
+ getSchemas(connectionId)
+ .then((schemas) => setAvailableSchemas(schemas))
+ .catch(() => setAvailableSchemas([]));
+ }, [connectionId]);
+
+ const handlePickFile = useCallback(async () => {
+ const picked = await open({
+ multiple: false,
+ filters: [
+ {
+ name: "Backup Files",
+ extensions: ["dump", "sql", "tar", "custom", "gz"],
+ },
+ ],
+ });
+ if (picked && typeof picked === "string") setFilePath(picked);
+ }, []);
+
+ const handleStartRestore = useCallback(async () => {
+ if (!filePath) {
+ notify("Please select a file path", "error");
+ return;
+ }
+ const jobId = `restore-${Date.now()}`;
+ startJob(jobId, "restore");
+ pendingJobRef.current = jobId;
+
+ try {
+ await pgRestore(connectionId, {
+ format,
+ filePath,
+ clean,
+ schema: schema || undefined,
+ });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ useBackupStore.getState().failJob(jobId, msg);
+ }
+ }, [filePath, format, clean, schema, connectionId, startJob, notify]);
+
+ const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
+ const canStart = filePath && confirmed && !isRunning;
+
+ return (
+
+ {/* Toolbar header */}
+
+
+ Restore
+
+ Restore a database from a backup file
+
+
+
+ {/* Content */}
+
+
+ {/* Tool check */}
+ {checkingTools && (
+
+
+ Checking for pg_restore...
+
+
+ )}
+
+ {toolsMissing && (
+
+
+ pg_restore not found
+
+
+ The PostgreSQL client tools are required for
+ backup/restore operations. Install them using:
+
+
+ {getPlatformInstructions()}
+
+
+ )}
+
+ {!checkingTools && !toolsMissing && (
+ <>
+ {/* Configuration card */}
+
+ {/* Format */}
+
+
+
+
+
+ {/* Backup file */}
+
+
+
+
+ setFilePath(e.target.value)
+ }
+ placeholder="/path/to/backup.dump"
+ className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
+ />
+
+
+
+
+
+
+ {/* Schema (optional) */}
+
+
+
+
+
+ {/* Clean toggle */}
+
+
+
+ {/* Destructive confirmation */}
+
+
+
+
+ {/* Progress */}
+ {activeJob && (
+
+
+
+ )}
+
+ {/* Actions */}
+
+
+
+ {isRunning
+ ? "Restoring..."
+ : "Start Restore"}
+
+
+ >
+ )}
+
+
+
+ );
+}
+
diff --git a/src/components/db-viewer/SyncDialog.tsx b/src/components/db-viewer/SyncDialog.tsx
new file mode 100644
index 0000000..8fcec0f
--- /dev/null
+++ b/src/components/db-viewer/SyncDialog.tsx
@@ -0,0 +1,202 @@
+import { useState, useEffect, useCallback } from "react";
+import { AnimatedModal } from "../ui/AnimatedModal";
+import { Button } from "../ui/Button";
+import { BackupProgress } from "./BackupProgress";
+import { useBackupStore } from "../../stores/backupStore";
+import { useConnectionStore } from "../../stores/connectionStore";
+import { useNotificationStore } from "../../stores/notificationStore";
+import { detectPgTools, dbSync } from "../../lib/commands";
+import type { PgToolStatus } from "../../lib/types";
+
+interface SyncDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export function SyncDialog({ open, onClose }: SyncDialogProps) {
+ const [sourceConnectionId, setSourceConnectionId] = useState("");
+ const [targetConnectionId, setTargetConnectionId] = useState("");
+ const [schema, setSchema] = useState("");
+ const [confirmed, setConfirmed] = useState(false);
+ const [toolStatus, setToolStatus] = useState(null);
+ const [checkingTools, setCheckingTools] = useState(false);
+ const [running, setRunning] = useState(false);
+
+ const connections = useConnectionStore((s) => s.connections);
+ const activeJobId = useBackupStore((s) => s.activeJobId);
+ const jobs = useBackupStore((s) => s.jobs);
+ const startJob = useBackupStore((s) => s.startJob);
+ const notify = useNotificationStore((s) => s.notify);
+
+ const activeJob = jobs.find((j) => j.id === activeJobId);
+
+ useEffect(() => {
+ if (!open) return;
+ setCheckingTools(true);
+ setConfirmed(false);
+ detectPgTools()
+ .then((status) => setToolStatus(status))
+ .catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
+ .finally(() => setCheckingTools(false));
+ }, [open]);
+
+ const handleStartSync = useCallback(async () => {
+ if (!sourceConnectionId || !targetConnectionId) {
+ notify("Please select both source and target connections", "error");
+ return;
+ }
+ if (sourceConnectionId === targetConnectionId) {
+ notify("Source and target must be different", "error");
+ return;
+ }
+ setRunning(true);
+ const jobId = `sync-${Date.now()}`;
+ startJob(jobId, "sync");
+ try {
+ await dbSync({
+ sourceConnectionId,
+ targetConnectionId,
+ schema: schema || undefined,
+ tables: undefined,
+ });
+ notify("Sync completed successfully", "success");
+ onClose();
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ notify(`Sync failed: ${parseError(msg)}`, "error");
+ } finally {
+ setRunning(false);
+ }
+ }, [sourceConnectionId, targetConnectionId, schema, startJob, notify, onClose]);
+
+ const toolsMissing = toolStatus && (!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
+ const canStart = sourceConnectionId && targetConnectionId && confirmed && !running;
+
+ const postgresqlConnections = connections.filter((c) => c.db_type === "postgresql");
+
+ return (
+
+
+
Sync Databases
+
+ {checkingTools && (
+
Checking for pg_dump/pg_restore...
+ )}
+
+ {toolsMissing && (
+
+
PostgreSQL tools not found
+
+ Both pg_dump and pg_restore are required for database sync.
+
+ {!toolStatus?.pg_dump_found && (
+
pg_dump is missing.
+ )}
+ {!toolStatus?.pg_restore_found && (
+
pg_restore is missing.
+ )}
+
+ )}
+
+ {!checkingTools && !toolsMissing && (
+
+ {/* Source connection */}
+
+
+
+
+
+ {/* Target connection */}
+
+
+
+
+
+ {/* Schema filter */}
+
+
+ setSchema(e.target.value)}
+ placeholder="public"
+ className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
+ />
+
+
+ {/* Destructive confirmation */}
+
+
+
+
+ {/* Progress */}
+ {activeJob?.status === "running" && (
+
+ )}
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+ {running ? "Syncing..." : "Start Sync"}
+
+
+
+ )}
+
+
+ );
+}
+
+function parseError(msg: string): string {
+ if (msg.includes("pg_dump:") || msg.includes("pg_restore:")) {
+ const parts = msg.split(/pg_(dump|restore):/);
+ return parts[parts.length - 1]?.trim() || msg;
+ }
+ if (msg.includes("No such file or directory")) {
+ return `File not found. Check the output path and try again.`;
+ }
+ if (msg.includes("Permission denied")) {
+ return `Permission denied. Check file permissions.`;
+ }
+ return msg;
+}
\ No newline at end of file
diff --git a/src/components/db-viewer/SyncPage.tsx b/src/components/db-viewer/SyncPage.tsx
new file mode 100644
index 0000000..7202298
--- /dev/null
+++ b/src/components/db-viewer/SyncPage.tsx
@@ -0,0 +1,327 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+import { ArrowLeftRight, Database } from "lucide-react";
+import { Button } from "../ui/Button";
+import { BackupProgress } from "./BackupProgress";
+import { useBackupStore } from "../../stores/backupStore";
+import { useConnectionStore } from "../../stores/connectionStore";
+import { useNotificationStore } from "../../stores/notificationStore";
+import { detectPgTools, dbSync, getSchemas } from "../../lib/commands";
+import type { PgToolStatus } from "../../lib/types";
+
+export function SyncPage() {
+ const [sourceConnectionId, setSourceConnectionId] = useState("");
+ const [targetConnectionId, setTargetConnectionId] = useState("");
+ const [schema, setSchema] = useState("");
+ const [confirmed, setConfirmed] = useState(false);
+ const [toolStatus, setToolStatus] = useState(null);
+ const [checkingTools, setCheckingTools] = useState(true);
+ const [availableSchemas, setAvailableSchemas] = useState([]);
+
+ const connections = useConnectionStore((s) => s.connections);
+ const activeJobId = useBackupStore((s) => s.activeJobId);
+ const jobs = useBackupStore((s) => s.jobs);
+ const startJob = useBackupStore((s) => s.startJob);
+ const notify = useNotificationStore((s) => s.notify);
+
+ const activeJob = jobs.find((j) => j.id === activeJobId);
+ const isRunning = activeJob?.status === "running";
+ const pendingJobRef = useRef(null);
+
+ useEffect(() => {
+ if (!pendingJobRef.current || !activeJob) return;
+ if (activeJob.id !== pendingJobRef.current) return;
+
+ if (activeJob.status === "completed") {
+ notify("Sync completed successfully", "success");
+ pendingJobRef.current = null;
+ } else if (activeJob.status === "failed") {
+ notify(
+ `Sync failed: ${activeJob.error_message || "Unknown error"}`,
+ "error",
+ );
+ pendingJobRef.current = null;
+ }
+ }, [activeJob, notify]);
+
+ useEffect(() => {
+ setCheckingTools(true);
+ setConfirmed(false);
+ detectPgTools()
+ .then((status) => setToolStatus(status))
+ .catch(() =>
+ setToolStatus({
+ pg_dump_found: false,
+ pg_restore_found: false,
+ pg_dump_version: null,
+ pg_restore_version: null,
+ }),
+ )
+ .finally(() => setCheckingTools(false));
+ }, []);
+
+ // Fetch schemas from the source connection when it changes
+ useEffect(() => {
+ if (!sourceConnectionId) {
+ setAvailableSchemas([]);
+ setSchema("");
+ return;
+ }
+ getSchemas(sourceConnectionId)
+ .then((schemas) => setAvailableSchemas(schemas))
+ .catch(() => setAvailableSchemas([]));
+ }, [sourceConnectionId]);
+
+ const handleStartSync = useCallback(async () => {
+ if (!sourceConnectionId || !targetConnectionId) {
+ notify("Please select both source and target connections", "error");
+ return;
+ }
+ if (sourceConnectionId === targetConnectionId) {
+ notify("Source and target must be different", "error");
+ return;
+ }
+ const jobId = `sync-${Date.now()}`;
+ startJob(jobId, "sync");
+ pendingJobRef.current = jobId;
+
+ try {
+ await dbSync({
+ sourceConnectionId,
+ targetConnectionId,
+ schema: schema || undefined,
+ tables: undefined,
+ });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ useBackupStore.getState().failJob(jobId, msg);
+ }
+ }, [sourceConnectionId, targetConnectionId, schema, startJob, notify]);
+
+ const toolsMissing =
+ toolStatus &&
+ (!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
+ const canStart =
+ sourceConnectionId && targetConnectionId && confirmed && !isRunning;
+
+ const postgresqlConnections = connections.filter(
+ (c) => c.db_type === "postgresql",
+ );
+
+ return (
+
+ {/* Toolbar header */}
+
+
+
DB Sync
+
+ Transfer data between PostgreSQL databases via pipe
+
+
+
+ {/* Content */}
+
+
+ {/* Tool check */}
+ {checkingTools && (
+
+
+ Checking for pg_dump / pg_restore...
+
+
+ )}
+
+ {toolsMissing && (
+
+
+ PostgreSQL tools not found
+
+
+ Both pg_dump and pg_restore are required for
+ database sync.
+
+
+ {!toolStatus?.pg_dump_found && (
+ - pg_dump is missing.
+ )}
+ {!toolStatus?.pg_restore_found && (
+ - pg_restore is missing.
+ )}
+
+
+ )}
+
+ {!checkingTools && !toolsMissing && (
+ <>
+ {/* Configuration card */}
+
+ {/* Source & Target connection pickers */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Schema (optional) */}
+
+
+
+
+
+ {/* Flow indicator */}
+ {sourceConnectionId && targetConnectionId && (
+
+
+ {postgresqlConnections.find(
+ (c) =>
+ c.id === sourceConnectionId,
+ )?.name ?? sourceConnectionId}
+
+
+
+ {postgresqlConnections.find(
+ (c) =>
+ c.id === targetConnectionId,
+ )?.name ?? targetConnectionId}
+
+
+ )}
+
+
+ {/* Destructive confirmation */}
+
+
+
+
+ {/* Progress */}
+ {activeJob && (
+
+
+
+ )}
+
+ {/* Actions */}
+
+
+
+ {isRunning ? "Syncing..." : "Start Sync"}
+
+
+ >
+ )}
+
+
+
+ );
+}
+
diff --git a/src/components/db-viewer/TabBar.tsx b/src/components/db-viewer/TabBar.tsx
index d1a7902..697f7fe 100644
--- a/src/components/db-viewer/TabBar.tsx
+++ b/src/components/db-viewer/TabBar.tsx
@@ -27,20 +27,17 @@ export function TabBar() {
key={tab.id}
role="tab"
aria-selected={isActive}
+ onClick={() => setActiveTab(tab.id)}
className={[
- "group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors",
+ "group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-pointer",
isActive
? "bg-canvas text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
- setActiveTab(tab.id)}
- className="flex-1 text-left outline-none cursor-pointer"
- >
+
{tab.table}
-
+
{
diff --git a/src/components/db-viewer/TableControls.tsx b/src/components/db-viewer/TableControls.tsx
index db27223..8b22989 100644
--- a/src/components/db-viewer/TableControls.tsx
+++ b/src/components/db-viewer/TableControls.tsx
@@ -17,7 +17,7 @@ const AUTO_REFRESH_OPTIONS = [
{ label: "5m", value: 300_000 },
] as const;
-const PAGE_SIZES = [50, 100, 200] as const;
+const PAGE_SIZES = [50, 100, 200, 500] as const;
const EXPORT_FORMATS = [
{ label: "JSON", ext: "json" },
@@ -560,7 +560,7 @@ export function TableControls({
};
return (
-
+
{/* ── left side ──────────────────────────────── */}
{/* Insert Row */}
diff --git a/src/components/db-viewer/TableTree.tsx b/src/components/db-viewer/TableTree.tsx
index 70c76b1..322b23a 100644
--- a/src/components/db-viewer/TableTree.tsx
+++ b/src/components/db-viewer/TableTree.tsx
@@ -8,107 +8,149 @@ import type { ColumnInfo } from "../../lib/types";
import * as cmd from "../../lib/commands";
export function TableTree({ searchQuery }: { searchQuery?: string }) {
- const tables = useDbViewerStore((s) => s.tables);
- const currentSchema = useDbViewerStore((s) => s.currentSchema);
- const openTab = useDbViewerStore((s) => s.openTab);
- const connectionId = useUiStore((s) => s.activeConnectionId);
- const [expanded, setExpanded] = useState
>(new Set());
- const [columnCache, setColumnCache] = useState>({});
+ const tables = useDbViewerStore((s) => s.tables);
+ const currentSchema = useDbViewerStore((s) => s.currentSchema);
+ const openTab = useDbViewerStore((s) => s.openTab);
+ const connectionId = useUiStore((s) => s.activeConnectionId);
+ const [expanded, setExpanded] = useState>(new Set());
+ const [columnCache, setColumnCache] = useState<
+ Record
+ >({});
- const q = (searchQuery ?? "").toLowerCase().trim();
+ const q = (searchQuery ?? "").toLowerCase().trim();
- const filteredTables = (currentSchema
- ? tables.filter((t) => t.schema === currentSchema)
- : tables).filter((t) => !q || t.name.toLowerCase().includes(q));
+ const filteredTables = (
+ currentSchema
+ ? tables.filter((t) => t.schema === currentSchema)
+ : tables
+ ).filter((t) => !q || t.name.toLowerCase().includes(q));
- const toggle = async (key: string, schema: string, tableName: string) => {
- const isExpanded = expanded.has(key);
- setExpanded((prev) => {
- const next = new Set(prev);
- if (isExpanded) next.delete(key);
- else next.add(key);
- return next;
- });
- // Fetch columns if not cached
- if (!isExpanded && !columnCache[key] && connectionId) {
- try {
- const result = await cmd.getTableData(connectionId, schema, tableName, 1, 0);
- setColumnCache((prev) => ({ ...prev, [key]: result.columns }));
- } catch { /* ignore, columns will remain unknowns */ }
- }
- };
-
- const handleOpenTab = (schema: string, table: string, forceNew?: boolean) => {
- openTab(schema, table, forceNew);
- return "tab";
- };
-
- return (
-
- {filteredTables.length === 0 && (
-
No tables
- )}
- {filteredTables.map((table) => {
- const key = `${table.schema}.${table.name}`;
+ const toggle = async (key: string, schema: string, tableName: string) => {
const isExpanded = expanded.has(key);
- const cols = columnCache[key] ?? table.columns ?? [];
- return (
-
-
openTab(table.schema, table.name)}
- >
-
{
- e.stopPropagation();
- toggle(key, table.schema, table.name);
- }}
- className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
- >
- {isExpanded ? : }
-
-
-
- {table.name}
-
-
e.stopPropagation()}>
-
-
-
- {isExpanded && (
-
- {cols.length === 0 && (
-
No columns
- )}
- {cols.map((col) => (
-
- {col.is_pk ? (
-
- ) : col.is_fk ? (
-
- ) : (
-
- )}
- {col.name}
- {abbreviateType(col.data_type)}
-
- ))}
-
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (isExpanded) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ // Fetch columns if not cached
+ if (!isExpanded && !columnCache[key] && connectionId) {
+ try {
+ const result = await cmd.getTableData(
+ connectionId,
+ schema,
+ tableName,
+ 1,
+ 0,
+ );
+ setColumnCache((prev) => ({ ...prev, [key]: result.columns }));
+ } catch {
+ /* ignore, columns will remain unknowns */
+ }
+ }
+ };
+
+ const handleOpenTab = (
+ schema: string,
+ table: string,
+ forceNew?: boolean,
+ ) => {
+ openTab(schema, table, forceNew);
+ return "tab";
+ };
+
+ return (
+
+ {filteredTables.length === 0 && (
+
+ No tables
+
)}
-
- );
- })}
-
- );
-}
\ No newline at end of file
+ {filteredTables.map((table) => {
+ const key = `${table.schema}.${table.name}`;
+ const isExpanded = expanded.has(key);
+ const cols = columnCache[key] ?? table.columns ?? [];
+ return (
+
+
openTab(table.schema, table.name)}
+ >
+
{
+ e.stopPropagation();
+ toggle(key, table.schema, table.name);
+ }}
+ className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {table.name}
+
+
e.stopPropagation()}>
+
+
+
+ {isExpanded && (
+
+ {cols.length === 0 && (
+
+ No columns
+
+ )}
+ {cols.map((col) => (
+
+ {col.is_pk ? (
+
+ ) : col.is_fk ? (
+
+ ) : (
+
+ )}
+
+ {col.name}
+
+
+ {abbreviateType(col.data_type)}
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/folders/FolderTree.test.tsx b/src/components/folders/FolderTree.test.tsx
index 3f0b46e..7cca63c 100644
--- a/src/components/folders/FolderTree.test.tsx
+++ b/src/components/folders/FolderTree.test.tsx
@@ -1,9 +1,14 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { DndContext } from "@dnd-kit/core";
import { FolderTree } from "./FolderTree";
import type { Folder } from "../../lib/types";
+function Wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
const folders: Folder[] = [
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "ClientA", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
@@ -11,7 +16,7 @@ const folders: Folder[] = [
describe("FolderTree", () => {
it("renders all folders", () => {
- render( {}} />);
+ render( {}} />, { wrapper: Wrapper });
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("ClientA")).toBeInTheDocument();
});
@@ -19,7 +24,7 @@ describe("FolderTree", () => {
it("renders All Connections option that clears filter", async () => {
const user = userEvent.setup();
const fn = vi.fn();
- render();
+ render(, { wrapper: Wrapper });
await user.click(screen.getByText(/all connections/i));
expect(fn).toHaveBeenCalledWith(null);
});
@@ -27,7 +32,7 @@ describe("FolderTree", () => {
it("selecting a folder calls onSelect with id", async () => {
const user = userEvent.setup();
const fn = vi.fn();
- render();
+ render(, { wrapper: Wrapper });
await user.click(screen.getByText("Work"));
expect(fn).toHaveBeenCalledWith("f1");
});
diff --git a/src/components/folders/FolderTree.tsx b/src/components/folders/FolderTree.tsx
index dad8c63..3517b03 100644
--- a/src/components/folders/FolderTree.tsx
+++ b/src/components/folders/FolderTree.tsx
@@ -1,5 +1,6 @@
import type { Folder } from "../../lib/types";
import { ChevronRight, Folder as FolderIcon } from "lucide-react";
+import { useDroppable } from "@dnd-kit/core";
interface FolderTreeProps {
folders: Folder[];
@@ -10,19 +11,31 @@ interface FolderTreeProps {
export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProps) {
const roots = folders.filter((f) => f.parent_id === null);
const childrenOf = (id: string) => folders.filter((f) => f.parent_id === id);
+ const { setNodeRef: setRootRef, isOver: isRootOver } = useDroppable({
+ id: "root",
+ });
- const renderFolder = (folder: Folder, depth: number) => {
+ const FolderItem = ({ folder, depth }: { folder: Folder; depth: number }) => {
const isActive = activeFolderId === folder.id;
+ const { setNodeRef: setDropRef, isOver } = useDroppable({
+ id: `folder-${folder.id}`,
+ data: { type: "folder", folder },
+ });
return (
onSelect(folder.id)}
- className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${isActive ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"}`}
+ className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
+ isActive ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"
+ } ${isOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{folder.name}
- {childrenOf(folder.id).map((c) => renderFolder(c, depth + 1))}
+ {childrenOf(folder.id).map((c) => (
+
+ ))}
);
};
@@ -30,12 +43,17 @@ export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProp
return (
onSelect(null)}
- className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${activeFolderId === null ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"}`}
+ className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
+ activeFolderId === null ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"
+ } ${isRootOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
>
All Connections
- {roots.map((r) => renderFolder(r, 0))}
+ {roots.map((r) => (
+
+ ))}
);
}
\ No newline at end of file
diff --git a/src/components/grid/VirtualDataGrid.test.tsx b/src/components/grid/VirtualDataGrid.test.tsx
new file mode 100644
index 0000000..da2c823
--- /dev/null
+++ b/src/components/grid/VirtualDataGrid.test.tsx
@@ -0,0 +1,245 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { VirtualDataGrid } from "./VirtualDataGrid";
+import type { ColumnInfo } from "../../lib/types";
+
+const mockColumns: ColumnInfo[] = [
+ { name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
+ { name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
+];
+
+const mockRows: unknown[][] = [
+ [1, "Alice"],
+ [2, "Bob"],
+];
+
+/**
+ * Override `useVirtualizer` so virtual items are always rendered
+ * regardless of container dimensions in jsdom.
+ */
+const { mockGetVirtualItems, mockGetTotalSize, mockMeasureElement } = vi.hoisted(() => ({
+ mockGetVirtualItems: vi.fn(),
+ mockGetTotalSize: vi.fn(),
+ mockMeasureElement: vi.fn(),
+}));
+
+vi.mock("@tanstack/react-virtual", () => ({
+ useVirtualizer: () => ({
+ getVirtualItems: mockGetVirtualItems,
+ getTotalSize: mockGetTotalSize,
+ measureElement: mockMeasureElement,
+ }),
+}));
+
+describe("VirtualDataGrid", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders all rows when row count is small", () => {
+ mockGetTotalSize.mockReturnValue(mockRows.length * 36);
+ mockGetVirtualItems.mockReturnValue(
+ mockRows.map((_, i) => ({
+ key: i,
+ index: i,
+ start: i * 36,
+ size: 36,
+ })),
+ );
+
+ render(
+ {}}
+ onToggleAll={() => {}}
+ />,
+ );
+
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ expect(screen.getByText("Bob")).toBeInTheDocument();
+ });
+
+ it("renders column headers with type badges", () => {
+ mockGetTotalSize.mockReturnValue(mockRows.length * 36);
+ mockGetVirtualItems.mockReturnValue(
+ mockRows.map((_, i) => ({
+ key: i,
+ index: i,
+ start: i * 36,
+ size: 36,
+ })),
+ );
+
+ render(
+ {}}
+ onToggleAll={() => {}}
+ />,
+ );
+
+ expect(screen.getByText("id")).toBeInTheDocument();
+ expect(screen.getByText("name")).toBeInTheDocument();
+ expect(screen.getByText("int")).toBeInTheDocument();
+ });
+
+ it("renders NULL values in italic", () => {
+ const rows: unknown[][] = [[null, "HasNull"]];
+ mockGetTotalSize.mockReturnValue(rows.length * 36);
+ mockGetVirtualItems.mockReturnValue(
+ rows.map((_, i) => ({
+ key: i,
+ index: i,
+ start: i * 36,
+ size: 36,
+ })),
+ );
+
+ render(
+ {}}
+ onToggleAll={() => {}}
+ />,
+ );
+
+ expect(screen.getByText("NULL")).toBeInTheDocument();
+ expect(screen.getByText("NULL").className).toContain("italic");
+ });
+
+ it("renders empty state when no rows", () => {
+ mockGetTotalSize.mockReturnValue(0);
+ mockGetVirtualItems.mockReturnValue([]);
+
+ render(
+ {}}
+ onToggleAll={() => {}}
+ />,
+ );
+
+ expect(screen.getByText(/no rows/i)).toBeInTheDocument();
+ });
+
+ it("calls onToggleRow when checkbox clicked", () => {
+ let toggled = -1;
+ mockGetTotalSize.mockReturnValue(mockRows.length * 36);
+ mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
+
+ render( { toggled = i; }} onToggleAll={() => {}} />);
+
+ const checkboxes = screen.getAllByRole("checkbox");
+ fireEvent.click(checkboxes[1]); // first row checkbox
+ expect(toggled).toBe(0);
+ });
+
+ it("renders FK cells with clickable underline styling", () => {
+ const fkCols: ColumnInfo[] = [
+ { name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null },
+ ];
+ mockGetTotalSize.mockReturnValue(36);
+ mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
+
+ render( {}} onToggleAll={() => {}} />);
+
+ const fkCell = screen.getByText("42");
+ expect(fkCell.className).toContain("cursor-pointer");
+ expect(fkCell.className).toContain("underline");
+ });
+
+ it("renders JSON cells with preview label", () => {
+ const jsonCols: ColumnInfo[] = [
+ { name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
+ ];
+ mockGetTotalSize.mockReturnValue(36);
+ mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
+
+ render( {}} onToggleAll={() => {}} />);
+
+ expect(screen.getByText(/2 keys/)).toBeInTheDocument();
+ });
+
+ it("has resize handles on column headers", () => {
+ mockGetTotalSize.mockReturnValue(0);
+ mockGetVirtualItems.mockReturnValue([]);
+
+ render( {}} onToggleAll={() => {}} />);
+
+ const handles = document.querySelectorAll('[class*="cursor-col-resize"]');
+ expect(handles.length).toBe(2); // one per visible column
+ });
+
+ it("renders 10000 rows without crashing (virtualization)", () => {
+ const bigRows: unknown[][] = Array.from({ length: 10000 }, (_, i) => [i, `Name${i}`]);
+ mockGetTotalSize.mockReturnValue(10000 * 36);
+ mockGetVirtualItems.mockReturnValue(
+ Array.from({ length: 20 }, (_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
+ );
+ render(
+ {}} onToggleAll={() => {}} />,
+ );
+ const checkboxes = screen.getAllByRole("checkbox");
+ expect(checkboxes.length).toBeLessThan(50); // virtualized: only visible rows + select all
+ });
+
+ it("shows select-all as checked when all rows selected", () => {
+ const allSelected = new Set([0, 1]);
+ mockGetTotalSize.mockReturnValue(mockRows.length * 36);
+ mockGetVirtualItems.mockReturnValue(
+ mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
+ );
+ render(
+ {}} onToggleAll={() => {}} />,
+ );
+ const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement;
+ expect(selectAll.checked).toBe(true);
+ });
+
+ it("hides columns in hiddenColumns set", () => {
+ const hidden = new Set(["name"]);
+ mockGetTotalSize.mockReturnValue(mockRows.length * 36);
+ mockGetVirtualItems.mockReturnValue(
+ mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
+ );
+ render(
+ {}} onToggleAll={() => {}} />,
+ );
+ expect(screen.queryByText("name")).not.toBeInTheDocument();
+ expect(screen.getByText("id")).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/components/grid/VirtualDataGrid.tsx b/src/components/grid/VirtualDataGrid.tsx
new file mode 100644
index 0000000..afcfa27
--- /dev/null
+++ b/src/components/grid/VirtualDataGrid.tsx
@@ -0,0 +1,319 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { Key, Braces } from "lucide-react";
+import type { ColumnInfo } from "../../lib/types";
+import { abbreviateType } from "../../lib/utils";
+import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
+import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
+
+interface VirtualDataGridProps {
+ connectionId: string;
+ schema: string;
+ rows: unknown[][];
+ columns: ColumnInfo[];
+ hiddenColumns: Set;
+ selectedRows: Set;
+ onToggleRow: (rowIndex: number) => void;
+ onToggleAll: () => void;
+}
+
+const ROW_HEIGHT = 36;
+const DEFAULT_COL_WIDTH = 200;
+const MIN_COL_WIDTH = 60;
+const MAX_COL_WIDTH = 800;
+
+export function VirtualDataGrid({
+ connectionId,
+ schema,
+ rows,
+ columns,
+ hiddenColumns,
+ selectedRows,
+ onToggleRow,
+ onToggleAll,
+}: VirtualDataGridProps) {
+ const parentRef = useRef(null);
+
+ const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
+ const allSelected = rows.length > 0 && selectedRows.size === rows.length;
+ const selectAllRef = useRef(null);
+
+ // Indeterminate state for partial selection
+ useEffect(() => {
+ if (selectAllRef.current) {
+ selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length;
+ }
+ }, [selectedRows.size, rows.length]);
+
+ const virtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => ROW_HEIGHT,
+ overscan: 5,
+ });
+
+ // ── column widths ─────────────────────────────────────
+
+ const [colWidths, setColWidths] = useState>({});
+
+ const getWidth = useCallback(
+ (colName: string) => colWidths[colName] ?? DEFAULT_COL_WIDTH,
+ [colWidths],
+ );
+
+ // Total width for horizontal scroll support
+ const totalWidth = 40 + visibleColumns.reduce((sum, c) => sum + getWidth(c.name), 0);
+
+ const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
+
+ const startResize = useCallback(
+ (colName: string, e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ resizeRef.current = { col: colName, startX: e.clientX, startWidth: getWidth(colName) };
+
+ const onMove = (ev: MouseEvent) => {
+ const current = resizeRef.current;
+ if (!current) return;
+ const delta = ev.clientX - current.startX;
+ const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, current.startWidth + delta));
+ setColWidths((prev) => ({ ...prev, [current.col]: next }));
+ };
+
+ const onUp = () => {
+ resizeRef.current = null;
+ document.removeEventListener("mousemove", onMove);
+ document.removeEventListener("mouseup", onUp);
+ };
+
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ },
+ [getWidth],
+ );
+
+ const resetWidth = useCallback((colName: string) => {
+ setColWidths((prev) => {
+ const next = { ...prev };
+ delete next[colName];
+ return next;
+ });
+ }, []);
+
+ // ── FK preview popover state ──────────────────────────
+
+ const [fkPreview, setFkPreview] = useState<{
+ connectionId: string;
+ schema: string;
+ table: string;
+ column: string;
+ value: string;
+ anchorRect: DOMRect | null;
+ } | null>(null);
+
+ const handleFkClick = useCallback(
+ (col: ColumnInfo, cellValue: unknown, e: React.MouseEvent) => {
+ if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return;
+ const [refTable] = col.fk_ref;
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
+ setFkPreview({
+ connectionId,
+ schema,
+ table: refTable,
+ column: col.fk_ref[1],
+ value: String(cellValue),
+ anchorRect: rect,
+ });
+ },
+ [connectionId, schema],
+ );
+
+ // ── JSON popover state ────────────────────────────────
+
+ const [jsonPopover, setJsonPopover] = useState<{
+ value: unknown;
+ anchorRect: DOMRect | null;
+ } | null>(null);
+
+ // ── cell renderer (shared between header sizing and body) ──
+
+ const renderCell = useCallback(
+ (col: ColumnInfo, row: unknown[], _rowIndex: number) => {
+ const ci = columns.findIndex((c) => c.name === col.name);
+ const cell = ci >= 0 ? row[ci] : undefined;
+ const isNull = cell === null || cell === undefined;
+ const isFk = col.is_fk && col.fk_ref && !isNull;
+ const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
+ const jp = isJson ? jsonPreview(cell) : { label: "", isJson: false };
+
+ const handleJsonClick = (e: React.MouseEvent) => {
+ if (isJson) {
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
+ setJsonPopover({ value: cell, anchorRect: rect });
+ }
+ };
+
+ return (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ if (isFk) handleFkClick(col, cell, e as any);
+ else if (isJson) {
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
+ setJsonPopover({ value: cell, anchorRect: rect });
+ }
+ }
+ }
+ : undefined
+ }
+ style={{ width: getWidth(col.name), flexShrink: 0 }}
+ title={
+ isNull
+ ? "NULL"
+ : isFk
+ ? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(cell)}`
+ : isJson
+ ? "Click to view JSON"
+ : String(cell)
+ }
+ onClick={
+ isFk
+ ? (e) => handleFkClick(col, cell, e)
+ : isJson
+ ? handleJsonClick
+ : undefined
+ }
+ >
+ {isNull ? (
+ NULL
+ ) : isJson ? (
+
+
+ {jp.label}
+
+ ) : (
+ String(cell)
+ )}
+
+ );
+ },
+ [columns, getWidth, handleFkClick],
+ );
+
+ return (
+
+ {/* ── sticky header ── */}
+
+
+
+
+
+ {visibleColumns.map((col) => (
+
+
+ {col.is_pk && }
+ {col.is_fk && }
+ {col.name}
+
+ {abbreviateType(col.data_type)}
+
+
+
startResize(col.name, e)}
+ onDoubleClick={() => resetWidth(col.name)}
+ />
+
+ ))}
+
+
+
+ {/* ── virtual body ── */}
+ {rows.length === 0 ? (
+
+ No rows in result set
+
+ ) : (
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const row = rows[virtualRow.index];
+ const isSelected = selectedRows.has(virtualRow.index);
+ return (
+
+
+ onToggleRow(virtualRow.index)}
+ className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
+ />
+
+ {visibleColumns.map((col) => renderCell(col, row, virtualRow.index))}
+
+ );
+ })}
+
+ )}
+
+ {/* FK preview popover */}
+ {fkPreview && (
+
setFkPreview(null)}
+ />
+ )}
+ {/* JSON cell popover */}
+ {jsonPopover && (
+ setJsonPopover(null)}
+ />
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/layout/HomeScreen.tsx b/src/components/layout/HomeScreen.tsx
index d641d64..27338a7 100644
--- a/src/components/layout/HomeScreen.tsx
+++ b/src/components/layout/HomeScreen.tsx
@@ -1,4 +1,5 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { DndContext, DragOverlay, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { useConnectionStore } from "../../stores/connectionStore";
import { useUiStore } from "../../stores/uiStore";
import { useFilteredConnections } from "../../hooks/useConnections";
@@ -7,6 +8,7 @@ import { SearchBar } from "../search/SearchBar";
import type { SearchBarHandle } from "../search/SearchBar";
import { ActionRow } from "./ActionRow";
import { ConnectionGrid } from "../connections/ConnectionGrid";
+import { ConnectionCard } from "../connections/ConnectionCard";
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
import { EditFolderDialog } from "../folders/EditFolderDialog";
import { ConfirmDialog } from "../ui/ConfirmDialog";
@@ -36,6 +38,7 @@ export function HomeScreen() {
type: "folder" | "selected";
folder?: Folder;
} | null>(null);
+ const [activeDragId, setActiveDragId] = useState
(null);
const searchRef = useRef(null);
const setSearchQuery = useUiStore((s) => s.setSearchQuery);
const setPrefilledConnectionString = useUiStore(
@@ -55,6 +58,29 @@ export function HomeScreen() {
setActiveView("new-connection");
};
+ const handleDragEnd = useCallback(async (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over) return;
+
+ const connectionId = active.id as string;
+ let folderId: string | null = null;
+
+ if (over.id === "root") {
+ folderId = null;
+ } else if (typeof over.id === "string" && over.id.startsWith("folder-")) {
+ const folderData = (over.data.current as any)?.folder;
+ folderId = folderData?.id ?? null;
+ } else {
+ return; // dropped on something unexpected
+ }
+
+ try {
+ await useConnectionStore.getState().moveConnection(connectionId, folderId);
+ } catch {
+ // Error handling in store; no additional action needed here
+ }
+ }, []);
+
// Cmd+K to focus search (configurable in Settings → Shortcuts)
useShortcut("command_palette", () => {
searchRef.current?.focus();
@@ -140,20 +166,41 @@ export function HomeScreen() {
visibleItemIds={visibleItemIds}
/>
- 0}
- onTagToggle={toggleTag}
- onOpenDbViewer={handleOpenDbViewer}
- onEditFolder={(f) => setEditFolder(f)}
- onDeleteFolder={(f) =>
- setConfirmDelete({ type: "folder", folder: f })
- }
- />
+ setActiveDragId(event.active.id as string)}
+ onDragEnd={async (event) => {
+ setActiveDragId(null);
+ await handleDragEnd(event);
+ }}
+ collisionDetection={closestCenter}
+ >
+ 0}
+ onTagToggle={toggleTag}
+ onOpenDbViewer={handleOpenDbViewer}
+ onEditFolder={(f) => setEditFolder(f)}
+ onDeleteFolder={(f) =>
+ setConfirmDelete({ type: "folder", folder: f })
+ }
+ />
+
+ {activeDragId && connections.find((c) => c.id === activeDragId) ? (
+
+ c.id === activeDragId)!}
+ tags={tags}
+ onTagToggle={() => {}}
+ onOpenDbViewer={() => {}}
+ />
+
+ ) : null}
+
+
{
- return invoke("get_table_data", { connectionId, schema, table, page, pageSize });
+ return invoke("get_table_data", { connectionId, schema, table, page, pageSize, filters, sorts });
}
export async function executeChange(connectionId: string, change: ChangeItem): Promise {
@@ -96,4 +99,44 @@ export async function getFkPreview(
export async function refreshConnection(connectionId: string): Promise {
return invoke("refresh_connection", { connectionId });
+}
+
+// ─── Backup / Restore / Sync ──────────────────────────────────
+
+export async function detectPgTools(): Promise {
+ return invoke("detect_pg_tools");
+}
+
+export async function pgDump(connectionId: string, options: BackupOptions): Promise {
+ return invoke("pg_dump", { connectionId, options });
+}
+
+export async function pgRestore(connectionId: string, options: RestoreOptions): Promise {
+ return invoke("pg_restore", { connectionId, options });
+}
+
+export async function dbSync(options: SyncOptions): Promise {
+ return invoke("db_sync", { options });
+}
+
+// ─── Object Explorer (Functions, Triggers, Sequences, Enums, Extensions) ────
+
+export async function getFunctions(connectionId: string, schema?: string): Promise {
+ return invoke("get_functions", { connectionId, schema });
+}
+
+export async function getTriggers(connectionId: string, schema?: string): Promise {
+ return invoke("get_triggers", { connectionId, schema });
+}
+
+export async function getSequences(connectionId: string, schema?: string): Promise {
+ return invoke("get_sequences", { connectionId, schema });
+}
+
+export async function getEnums(connectionId: string, schema?: string): Promise {
+ return invoke("get_enums", { connectionId, schema });
+}
+
+export async function getExtensions(connectionId: string): Promise {
+ return invoke("get_extensions", { connectionId });
}
\ No newline at end of file
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 3bb6b60..a8f78b5 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -180,9 +180,102 @@ export interface DbViewerTab {
updated_at: string;
}
+export interface FunctionInfo {
+ name: string;
+ schema: string;
+ return_type: string;
+ argument_types: string[];
+ argument_names: string[];
+ argument_modes: string[];
+ language: string;
+ source: string | null;
+ kind: string;
+}
+
+export interface TriggerInfo {
+ name: string;
+ schema: string;
+ table_schema: string;
+ table_name: string;
+ event_manipulation: string;
+ action_timing: string;
+ action_orientation: string;
+ action_statement: string;
+ enabled: string;
+}
+
+export interface SequenceInfo {
+ name: string;
+ schema: string;
+ start_value: string;
+ min_value: string;
+ max_value: string;
+ increment: string;
+ current_value: string;
+ cycle: boolean;
+}
+
+export interface EnumInfo {
+ name: string;
+ schema: string;
+ labels: string[];
+}
+
+export interface ExtensionInfo {
+ name: string;
+ schema: string;
+ version: string;
+ comment: string | null;
+}
+
export interface ConnectionTestResult {
ok: boolean;
error?: string | null;
server_version?: string | null;
latency_ms?: number | null;
+}
+
+// ─── Backup Types ────────────────────────────────────────────────
+
+export interface BackupOptions {
+ format: "plain" | "custom" | "tar" | "directory";
+ filePath: string;
+ schema?: string;
+ tables?: string[];
+ noOwner: boolean;
+}
+
+export interface RestoreOptions {
+ format: string;
+ filePath: string;
+ clean: boolean;
+ schema?: string;
+}
+
+export interface SyncOptions {
+ sourceConnectionId: string;
+ targetConnectionId: string;
+ schema?: string;
+ tables?: string[];
+}
+
+export interface PgToolStatus {
+ pg_dump_found: boolean;
+ pg_restore_found: boolean;
+ pg_dump_version: string | null;
+ pg_restore_version: string | null;
+}
+
+export interface BackupJob {
+ id: string;
+ connection_id: string;
+ type: "dump" | "restore" | "sync";
+ format: string | null;
+ file_path: string | null;
+ source_connection_id: string | null;
+ status: "running" | "completed" | "failed" | "cancelled";
+ error_message: string | null;
+ size_bytes: number | null;
+ started_at: string;
+ completed_at: string | null;
}
\ No newline at end of file
diff --git a/src/stores/backupStore.ts b/src/stores/backupStore.ts
new file mode 100644
index 0000000..b8a5b00
--- /dev/null
+++ b/src/stores/backupStore.ts
@@ -0,0 +1,82 @@
+import { create } from "zustand";
+import { listen } from "@tauri-apps/api/event";
+import type { BackupJob } from "../lib/types";
+
+interface BackupJobEvent {
+ job_id: string;
+ status: "running" | "completed" | "failed";
+ error?: string | null;
+}
+
+interface BackupStore {
+ jobs: BackupJob[];
+ activeJobId: string | null;
+ progress: number;
+ startJob: (jobId: string, type: string) => void;
+ completeJob: (jobId: string) => void;
+ failJob: (jobId: string, error: string) => void;
+ initListener: () => Promise;
+}
+
+export const useBackupStore = create((set, get) => ({
+ jobs: [],
+ activeJobId: null,
+ progress: 0,
+
+ startJob: (jobId: string, type: string) =>
+ set((s) => ({
+ activeJobId: jobId,
+ progress: 0,
+ jobs: [
+ ...s.jobs,
+ {
+ id: jobId,
+ connection_id: "",
+ type: type as BackupJob["type"],
+ format: null,
+ file_path: null,
+ source_connection_id: null,
+ status: "running",
+ error_message: null,
+ size_bytes: null,
+ started_at: new Date().toISOString(),
+ completed_at: null,
+ } satisfies BackupJob,
+ ],
+ })),
+
+ completeJob: (jobId: string) =>
+ set((s) => ({
+ progress: 100,
+ jobs: s.jobs.map((j) =>
+ j.id === jobId
+ ? { ...j, status: "completed" as const, completed_at: new Date().toISOString() }
+ : j,
+ ),
+ })),
+
+ failJob: (jobId: string, error: string) =>
+ set((s) => ({
+ jobs: s.jobs.map((j) =>
+ j.id === jobId
+ ? {
+ ...j,
+ status: "failed" as const,
+ error_message: error,
+ completed_at: new Date().toISOString(),
+ }
+ : j,
+ ),
+ })),
+
+ initListener: async () => {
+ await listen("backup-progress", (event) => {
+ const { job_id, status, error } = event.payload;
+ if (status === "completed") {
+ get().completeJob(job_id);
+ } else if (status === "failed") {
+ get().failJob(job_id, error || "Unknown error");
+ }
+ });
+ },
+}));
\ No newline at end of file
diff --git a/src/stores/connectionStore.test.ts b/src/stores/connectionStore.test.ts
index 2284509..f64c306 100644
--- a/src/stores/connectionStore.test.ts
+++ b/src/stores/connectionStore.test.ts
@@ -58,4 +58,93 @@ describe("connectionStore", () => {
await useConnectionStore.getState().createFolder({ name: "Work", parent_id: null });
expect(useConnectionStore.getState().folders).toContainEqual(folder);
});
+});
+
+describe("moveConnection", () => {
+ const baseConn: Connection = {
+ id: "c1",
+ name: "My DB",
+ db_type: "postgresql",
+ host: "localhost",
+ port: null,
+ username: null,
+ database: "mydb",
+ folder_id: null,
+ keychain_ref: null,
+ environment: null,
+ ssh_host: null,
+ ssh_port: null,
+ ssh_user: null,
+ ssh_auth_method: null,
+ ssh_private_key_path: null,
+ ssl_mode: null,
+ ssl_ca_path: null,
+ ssl_cert_path: null,
+ ssl_key_path: null,
+ tag_ids: [],
+ created_at: "2024-01-01",
+ updated_at: "2024-01-01",
+ };
+
+ beforeEach(() => {
+ useConnectionStore.setState({
+ connections: [
+ baseConn,
+ { ...baseConn, id: "c2", name: "Other", folder_id: "folder-1" },
+ ],
+ });
+ });
+
+ it("optimistically moves connection to a folder", async () => {
+ vi.spyOn(commands, "updateConnection").mockResolvedValueOnce({
+ ...baseConn,
+ folder_id: "folder-2",
+ } as Connection);
+
+ await useConnectionStore.getState().moveConnection("c1", "folder-2");
+
+ const conn = useConnectionStore
+ .getState()
+ .connections.find((c) => c.id === "c1");
+ expect(conn?.folder_id).toBe("folder-2");
+ });
+
+ it("moves connection to root when folderId is null", async () => {
+ vi.spyOn(commands, "updateConnection").mockResolvedValueOnce({
+ ...baseConn,
+ id: "c2",
+ folder_id: null,
+ } as Connection);
+
+ await useConnectionStore.getState().moveConnection("c2", null);
+
+ const conn = useConnectionStore
+ .getState()
+ .connections.find((c) => c.id === "c2");
+ expect(conn?.folder_id).toBeNull();
+ });
+
+ it("rolls back on API failure", async () => {
+ vi.spyOn(commands, "updateConnection").mockRejectedValueOnce(
+ new Error("Network error"),
+ );
+ const original = useConnectionStore
+ .getState()
+ .connections.find((c) => c.id === "c1")!;
+
+ await expect(
+ useConnectionStore.getState().moveConnection("c1", "folder-3"),
+ ).rejects.toThrow("Network error");
+
+ const conn = useConnectionStore
+ .getState()
+ .connections.find((c) => c.id === "c1");
+ expect(conn?.folder_id).toBe(original.folder_id);
+ });
+
+ it("no-ops when moving to same folder", async () => {
+ const spy = vi.spyOn(commands, "updateConnection");
+ await useConnectionStore.getState().moveConnection("c1", null); // c1 is already null
+ expect(spy).not.toHaveBeenCalled();
+ });
});
\ No newline at end of file
diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts
index a4da8fe..8e2c8c0 100644
--- a/src/stores/connectionStore.ts
+++ b/src/stores/connectionStore.ts
@@ -18,6 +18,7 @@ interface ConnectionState {
updateTag: (id: string, input: TagInput) => Promise;
deleteTag: (id: string) => Promise;
addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise;
+ moveConnection: (connectionId: string, newFolderId: string | null) => Promise;
cachePassword: (connectionId: string, password: string) => Promise;
getConnectionPassword: (connectionId: string) => Promise;
}
@@ -116,4 +117,47 @@ export const useConnectionStore = create((set, get) => ({
),
}));
},
+ moveConnection: async (connectionId, newFolderId) => {
+ const state = get();
+ const conn = state.connections.find((c) => c.id === connectionId);
+ if (!conn) return;
+ if (conn.folder_id === newFolderId) return;
+
+ const previousConnections = [...state.connections];
+
+ // Optimistic update
+ set((s) => ({
+ connections: s.connections.map((c) =>
+ c.id === connectionId ? { ...c, folder_id: newFolderId } : c,
+ ),
+ }));
+
+ try {
+ // Build a minimal ConnectionInput with only folder_id changed
+ const input: any = {
+ name: conn.name,
+ db_type: conn.db_type,
+ host: conn.host,
+ port: conn.port,
+ username: conn.username,
+ database: conn.database,
+ folder_id: newFolderId,
+ environment: conn.environment,
+ ssh_host: conn.ssh_host,
+ ssh_port: conn.ssh_port,
+ ssh_user: conn.ssh_user,
+ ssh_auth_method: conn.ssh_auth_method,
+ ssh_private_key_path: conn.ssh_private_key_path,
+ ssl_mode: conn.ssl_mode,
+ ssl_ca_path: conn.ssl_ca_path,
+ ssl_cert_path: conn.ssl_cert_path,
+ ssl_key_path: conn.ssl_key_path,
+ tag_ids: conn.tag_ids ?? [],
+ };
+ await cmd.updateConnection(connectionId, input);
+ } catch (e) {
+ set({ connections: previousConnections });
+ throw e;
+ }
+ },
}));
\ No newline at end of file
diff --git a/src/stores/dbViewerStore.ts b/src/stores/dbViewerStore.ts
index e3d40d0..9371e8a 100644
--- a/src/stores/dbViewerStore.ts
+++ b/src/stores/dbViewerStore.ts
@@ -1,10 +1,25 @@
import { create } from "zustand";
-import type { QueryResult, TableInfo, ChangeItemType } from "../lib/types";
+import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../lib/types";
// ─── Local types ────────────────────────────────────────────────
export type QueueStatus = "pending" | "cancelled" | "committed" | "failed";
+export type FilterOperator = "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
+
+export interface FilterRule {
+ id: string;
+ column: string;
+ operator: FilterOperator;
+ value: string;
+}
+
+export interface SortRule {
+ id: string;
+ column: string;
+ order: "asc" | "desc";
+}
+
export interface QueueItem {
id: string;
type: ChangeItemType;
@@ -30,6 +45,10 @@ export interface ViewerTab {
error: string | null;
data: QueryResult | null;
columnFilter?: { column: string; value: string };
+ filterRules: FilterRule[];
+ sortRules: SortRule[];
+ hiddenColumns: string[];
+ smartSortApplied: boolean;
}
// ─── Auto-increment counters ───────────────────────────────────
@@ -46,6 +65,10 @@ const initialTab = (schema: string, table: string, defaultPageSize?: number): Vi
loading: true,
error: null,
data: null,
+ filterRules: [],
+ sortRules: [],
+ hiddenColumns: [],
+ smartSortApplied: false,
});
// ─── State interface ────────────────────────────────────────────
@@ -60,6 +83,11 @@ interface DbViewerState {
tables: TableInfo[];
currentDatabase: string | null;
currentSchema: string | null;
+ functions: FunctionInfo[] | null;
+ triggers: TriggerInfo[] | null;
+ sequences: SequenceInfo[] | null;
+ enums: EnumInfo[] | null;
+ extensions: ExtensionInfo[] | null;
// Actions
openTab: (schema: string, table: string, forceNew?: boolean) => void;
@@ -73,6 +101,11 @@ interface DbViewerState {
setTabError: (tabId: string, error: string) => void;
setColumnFilter: (tabId: string, column: string, value: string) => void;
clearColumnFilter: (tabId: string) => void;
+ setFilterRules: (tabId: string, rules: FilterRule[]) => void;
+ setSortRules: (tabId: string, rules: SortRule[]) => void;
+ setHiddenColumns: (tabId: string, columns: string[]) => void;
+ toggleHiddenColumn: (tabId: string, column: string) => void;
+ setSmartSortApplied: (tabId: string) => void;
addChange: (input: {
type: ChangeItemType;
sql?: string;
@@ -88,6 +121,11 @@ interface DbViewerState {
markChangeFailed: (changeId: string, error: string) => void;
setCurrentDatabase: (db: string | null) => void;
setCurrentSchema: (schema: string | null) => void;
+ setFunctions: (functions: FunctionInfo[]) => void;
+ setTriggers: (triggers: TriggerInfo[]) => void;
+ setSequences: (sequences: SequenceInfo[]) => void;
+ setEnums: (enums: EnumInfo[]) => void;
+ setExtensions: (extensions: ExtensionInfo[]) => void;
populate: (
databases: string[],
schemas: string[],
@@ -108,6 +146,11 @@ const initialState = {
tables: [] as TableInfo[],
currentDatabase: null as string | null,
currentSchema: null as string | null,
+ functions: null as FunctionInfo[] | null,
+ triggers: null as TriggerInfo[] | null,
+ sequences: null as SequenceInfo[] | null,
+ enums: null as EnumInfo[] | null,
+ extensions: null as ExtensionInfo[] | null,
};
// ─── Store ──────────────────────────────────────────────────────
@@ -202,6 +245,48 @@ export const useDbViewerStore = create((set, get) => ({
),
})),
+ setFilterRules: (tabId, rules) =>
+ set((state) => ({
+ tabs: state.tabs.map((t) =>
+ t.id === tabId ? { ...t, filterRules: rules, page: 1, loading: true, error: null } : t,
+ ),
+ })),
+
+ setSortRules: (tabId, rules) =>
+ set((state) => ({
+ tabs: state.tabs.map((t) =>
+ t.id === tabId ? { ...t, sortRules: rules, page: 1, loading: true, error: null } : t,
+ ),
+ })),
+
+ setHiddenColumns: (tabId, columns) =>
+ set((state) => ({
+ tabs: state.tabs.map((t) =>
+ t.id === tabId ? { ...t, hiddenColumns: columns } : t,
+ ),
+ })),
+
+ toggleHiddenColumn: (tabId, column) =>
+ set((state) => ({
+ tabs: state.tabs.map((t) => {
+ if (t.id !== tabId) return t;
+ const exists = t.hiddenColumns.includes(column);
+ return {
+ ...t,
+ hiddenColumns: exists
+ ? t.hiddenColumns.filter((c) => c !== column)
+ : [...t.hiddenColumns, column],
+ };
+ }),
+ })),
+
+ setSmartSortApplied: (tabId) =>
+ set((state) => ({
+ tabs: state.tabs.map((t) =>
+ t.id === tabId ? { ...t, smartSortApplied: true } : t,
+ ),
+ })),
+
addChange: (input) => {
const item: QueueItem = {
id: `ch-${++changeCounter}`,
@@ -244,6 +329,11 @@ export const useDbViewerStore = create((set, get) => ({
setCurrentDatabase: (db) => set({ currentDatabase: db }),
setCurrentSchema: (schema) => set({ currentSchema: schema }),
+ setFunctions: (functions) => set({ functions }),
+ setTriggers: (triggers) => set({ triggers }),
+ setSequences: (sequences) => set({ sequences }),
+ setEnums: (enums) => set({ enums }),
+ setExtensions: (extensions) => set({ extensions }),
populate: (databases, schemas, tables) =>
set({ databases, schemas, tables }),