From 3dab09f25dee68c9d8e0e042f31ea85a32cf57fe Mon Sep 17 00:00:00 2001 From: "Adrian Alfred C. Bonpin" Date: Wed, 29 Jul 2026 03:23:52 +0800 Subject: [PATCH] =?UTF-8?q?v0.2.0=20=E2=80=94=20Object=20Explorer,=20Backu?= =?UTF-8?q?p/Restore/Sync,=20Server-Side=20Filters,=20ENUM=20support,=20an?= =?UTF-8?q?d=20more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(grid): VirtualDataGrid component with TanStack Virtual (Task A1) * fix(grid): address code review — header bg, row hover/select, table classes, measureElement removal * feat(grid): column resize, FK popover, JSON popover in VirtualDataGrid (Task A2) * fix(grid): address code review — accessibility, indeterminate checkbox, safe jp default * feat(grid): wire VirtualDataGrid into DbViewerScreen, increase page size to 500 (Task A3) * feat(grid): polish VirtualDataGrid — large datasets, hidden columns, select-all (Tasks A4+A5) * feat(dnd): moveConnection store action with optimistic update + rollback (Task D1) * feat(dnd): draggable connection cards + droppable folder tree (Task D2) * feat(dnd): wire DndContext, onDragEnd, DragOverlay, drop targets in ConnectionGrid (Tasks D3-D5) * feat(backup): models, v4 migration for backup_history, TypeScript types (Task C1) * feat(backup): pg_dump, pg_restore, detect_tools, db_sync commands (Task C2) * feat(backup): frontend wrappers, backup store, dialogs, tool detection, confirmations (Tasks C3-C5) * feat(objects): add FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo models (Task B1) * feat(objects): introspection queries + Tauri commands for functions, triggers, sequences, enums, extensions (Task B2) * feat(objects): ObjectTree component, sidebar navigation, search, source expand (Tasks B3-B5) * fix: drag handle position, env tag layout, transparent header, virtual row rendering * fix: remove table toolbar bg, fix sticky header with flex layout, match header/row widths * fix: vertical borders on all cells, horizontal border scrolls with header, checkbox column borders * fix: full-height cell borders, centered checkboxes, horizontal scroll borders * fix: header bg fills full width on scroll, page sizes match settings (50-500) * fix: add subtle bg to panel resize handle to eliminate visual gap * fix: FK preview showing 'db error' + make entire tab clickable - VirtualDataGrid: added schema prop to fix empty schema sent to FK preview, which produced invalid SQL (SELECT * FROM ""."table") - Rust get_fk_preview: use pg_error_message() instead of .to_string() so real PostgreSQL error messages surface instead of just 'db error' - TabBar: moved onClick to the outer tab container so the entire tab area is clickable, not just the table name text * feat: push filters and sorts to server-side SQL queries - Added FilterRule/SortRule models to Rust backend with serde support - Built dynamic WHERE (parameterized /?) and ORDER BY clauses - Both COUNT(*) and data queries now include filter clauses for correct pagination with filtered results - Store setFilterRules/setSortRules now trigger page-1 re-fetch - Removed client-side applyFilters/applySorts — data arrives pre-filtered/sorted from the database - Added column name validation (is_safe_identifier) to prevent SQL injection in filter/sort column names * chore: center drag handle vertically on connection cards * feat: convert backup/restore/sync from unreachable modals to in-page views - Added BackupPage, RestorePage, SyncPage as full-page views inside the DB viewer, replacing the old unused modal dialogs - Added sidebar navigation items: Backup (Download), Restore (Upload), DB Sync (ArrowLeftRight) - Wired routing in DbViewerScreen — currentView switches to the new page components, all scoped to the active connection - Styled with toolbar header, underline inputs, glass cards, and destructive confirmation checkboxes matching the app aesthetic - Auto-detects pg_dump/pg_restore availability with platform-specific install instructions when tools are missing * fix: backup/restore/sync event-driven flow and styling - Registered tauri-plugin-dialog in Rust lib.rs (was missing — browse buttons were silently failing) - Added #[serde(rename_all = "camelCase")] to BackupOptions, RestoreOptions, SyncOptions so frontend camelCase fields map correctly to Rust snake_case fields - Made backup/restore/sync fully event-driven: pages now derive running/completed/failed state from the backupStore instead of the pgDump/pgRestore/dbSync promise (which returns immediately while the actual work runs in the background) - Added initListener() call in App.tsx so the backupStore actually listens to Tauri backup-progress events - failJob no longer clears activeJobId so the component can still find the job to read its error message - BackupProgress now receives real job status and error_message; visible for all states (running/completed/failed) so the error bar renders on failure - Schema field converted from free-text to database-populated dropdown (getSchemas) on all three pages - Removed unused parseError functions and dead code - Progress bar shows 100% on completed, 50% indeterminate while running, red bar + error text on failure * feat: object explorer pages with split-pane layout and schema switching Created ObjectExplorerPage — a reusable split-pane component for Functions, Triggers, Sequences, Enums, and Extensions: Left panel: - Database/schema dropdowns for switching context (solves 'no functions showing' when they're in a non-default schema) - Searchable object list with animated search input - Refresh button to re-query - Selected item highlighting Right panel: - Detail view with icon, name, schema - Type-specific fields: - Functions: return type, language, kind, arguments (with mode/type badges), full source code with show/hide - Triggers: table (schema-qualified), event, timing, orientation, enabled status, trigger definition - Sequences: current value, increment, start, min, max, cycle flag - Enums: labeled pill badges for each enum value - Extensions: version, schema, comment - Empty state showing count of available objects Bug fix: pg_get_function_result() replaced with format_type() in Rust introspection — the former returns multiple rows for SETOF/TABLE functions (generate_series, etc.), causing the entire query to fail silently. This was the root cause of 'no functions showing even though I know there are'. * fix: correct PostgreSQL tgtype bitmask in trigger query The trigger query used wrong bit positions for all events. PostgreSQL tgtype bits: bit 0 (1): ROW vs STATEMENT bit 1 (2): BEFORE vs AFTER bit 2 (4): INSERT bit 3 (8): DELETE bit 4 (16): UPDATE bit 5 (32): TRUNCATE The old query had every event shifted one bit down (INSERT=2 instead of 4, DELETE=4 instead of 8, etc.). Timing checked bit 0 instead of bit 1. Orientation label was inverted (showed STATEMENT when bit 0=1 which actually means ROW). * fix: object page re-mount on type switch + matching dropdown style - Added key={type} to each ObjectExplorerPage instance so React forces a fresh mount when switching between Functions → Triggers → Sequences → Enums → Extensions (was reusing the same component instance, showing stale data from the previous type) - Replaced raw setFormat(v as BackupFormat)} + options={FORMAT_OPTIONS} + /> + + + {/* 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" + /> + +
+
+ + {/* 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" + /> +
+ + {/* No owner toggle */} + + + {/* Progress */} + {activeJob?.status === "running" && ( + + )} + + {/* Actions */} +
+ + +
+ + )} + + + ); +} + +function parseError(msg: string): string { + if (msg.includes("pg_dump:")) { + const [, ...rest] = msg.split("pg_dump:"); + return rest.join(":").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 for the output path.`; + } + return msg; +} \ No newline at end of file diff --git a/src/components/db-viewer/BackupPage.tsx b/src/components/db-viewer/BackupPage.tsx new file mode 100644 index 0000000..38338a2 --- /dev/null +++ b/src/components/db-viewer/BackupPage.tsx @@ -0,0 +1,305 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Download, FolderOpen, HardDrive } from "lucide-react"; +import { save } 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, pgDump, getSchemas } from "../../lib/commands"; +import type { PgToolStatus } from "../../lib/types"; + +interface BackupPageProps { + connectionId: string; +} + +type BackupFormat = "plain" | "custom" | "tar" | "directory"; + +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_dump 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 BackupPage({ connectionId }: BackupPageProps) { + const [format, setFormat] = useState("custom"); + const [filePath, setFilePath] = useState(""); + const [schema, setSchema] = useState(""); + const [noOwner, setNoOwner] = useState(true); + 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"; + + // Track our job ID so we only react to jobs we started + const pendingJobRef = useRef(null); + + // React to job completion/failure via store events + useEffect(() => { + if (!pendingJobRef.current || !activeJob) return; + if (activeJob.id !== pendingJobRef.current) return; + + if (activeJob.status === "completed") { + notify("Backup completed successfully", "success"); + pendingJobRef.current = null; + } else if (activeJob.status === "failed") { + notify( + `Backup failed: ${activeJob.error_message || "Unknown error"}`, + "error", + ); + pendingJobRef.current = null; + } + }, [activeJob, notify]); + + useEffect(() => { + setCheckingTools(true); + 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 extensions: Record = { + plain: ["sql"], + custom: ["dump", "custom"], + tar: ["tar"], + directory: [], + }; + const picked = await save({ + defaultPath: `backup.${ + format === "custom" + ? "dump" + : format === "plain" + ? "sql" + : "tar" + }`, + filters: [{ name: "Backup", extensions: extensions[format] }], + }); + if (picked) setFilePath(picked); + }, [format]); + + const handleStartBackup = useCallback(async () => { + if (!filePath) { + notify("Please select a file path", "error"); + return; + } + const jobId = `dump-${Date.now()}`; + startJob(jobId, "dump"); + pendingJobRef.current = jobId; + + try { + // pgDump returns the job ID immediately — completion + // comes via Tauri events handled by the backupStore + await pgDump(connectionId, { + format, + filePath, + schema: schema || undefined, + tables: undefined, + noOwner, + }); + } catch (e) { + // If the command itself fails (e.g. connection not found), + // the event won't fire — handle here + const msg = e instanceof Error ? e.message : String(e); + useBackupStore.getState().failJob(jobId, msg); + } + }, [filePath, format, schema, noOwner, connectionId, startJob, notify]); + + const toolsMissing = toolStatus && !toolStatus.pg_dump_found; + + return ( +
+ {/* Toolbar header */} +
+ + Backup + + Create a database backup via pg_dump + +
+ + {/* Content */} +
+
+ {/* Tool check */} + {checkingTools && ( +
+

+ Checking for pg_dump... +

+
+ )} + + {toolsMissing && ( +
+

+ pg_dump not found +

+

+ The PostgreSQL client tools are required for + backup/restore operations. Install them using: +

+
+                                {getPlatformInstructions()}
+                            
+
+ )} + + {!checkingTools && !toolsMissing && ( + <> + {/* Configuration card */} +
+ {/* Format */} +
+ + +
+ + {/* Output 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) */} +
+ + +
+ + {/* No-owner toggle */} + +
+ + {/* Progress */} + {activeJob && ( +
+ +
+ )} + + {/* Actions */} +
+ +
+ + )} +
+
+
+ ); +} + diff --git a/src/components/db-viewer/BackupProgress.tsx b/src/components/db-viewer/BackupProgress.tsx new file mode 100644 index 0000000..92a4fde --- /dev/null +++ b/src/components/db-viewer/BackupProgress.tsx @@ -0,0 +1,61 @@ +import { Button } from "../ui/Button"; + +interface BackupProgressProps { + progress: number; + jobType: string; + status: "running" | "completed" | "failed" | "cancelled"; + errorMessage?: string | null; + onCancel?: () => void; +} + +export function BackupProgress({ + progress, + jobType, + status, + errorMessage, + onCancel, +}: BackupProgressProps) { + const isRunning = status === "running"; + + return ( +
+ {/* Header row */} +
+
+ + {jobType} + + + {status === "running" && `In progress...`} + {status === "completed" && "Completed"} + {status === "failed" && "Failed"} + {status === "cancelled" && "Cancelled"} + +
+ {isRunning && onCancel && ( + + )} +
+ + {/* Progress bar */} +
+
+
+ + {/* Error message */} + {errorMessage && status === "failed" && ( +

{errorMessage}

+ )} +
+ ); +} diff --git a/src/components/db-viewer/DataGrid.test.tsx b/src/components/db-viewer/DataGrid.test.tsx deleted file mode 100644 index 6488f65..0000000 --- a/src/components/db-viewer/DataGrid.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { DataGrid } from "./DataGrid"; -import { useDbViewerStore } from "../../stores/dbViewerStore"; -import type { QueryResult } from "../../lib/types"; - -const mockData: QueryResult = { - columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}], - rows: [ - [1, "Alice", "alice@example.com"], - [2, "Bob", null], - ], - total_rows: 2, page: 1, page_size: 50, -}; - -describe("DataGrid", () => { - beforeEach(() => { - useDbViewerStore.getState().reset(); - }); - - it("shows empty state when no active tab", () => { - render( {}} />); - expect(screen.getByText(/Select a table to view data/i)).toBeInTheDocument(); - }); - - it("shows loading state", () => { - useDbViewerStore.getState().openTab("public", "users"); - const tabId = useDbViewerStore.getState().tabs[0].id; - useDbViewerStore.getState().setTabLoading(tabId, true); - - render( {}} />); - expect(screen.getByText(/Loading/i)).toBeInTheDocument(); - }); - - it("shows error message in red", () => { - useDbViewerStore.getState().openTab("public", "users"); - const tabId = useDbViewerStore.getState().tabs[0].id; - useDbViewerStore.getState().setTabError(tabId, "Connection failed"); - - render( {}} />); - const error = screen.getByText(/Connection failed/i); - expect(error).toBeInTheDocument(); - expect(error).toHaveClass("text-red-500"); - }); - - it("shows loading state when first opening a tab", () => { - useDbViewerStore.getState().openTab("public", "users"); - - render( {}} />); - expect(screen.getByText(/Loading/i)).toBeInTheDocument(); - }); - - it("renders column headers and row data when loaded", () => { - useDbViewerStore.getState().openTab("public", "users"); - const tabId = useDbViewerStore.getState().tabs[0].id; - useDbViewerStore.getState().setTabData(tabId, mockData); - - render( {}} />); - expect(screen.getByRole("columnheader", { name: /id/ })).toBeInTheDocument(); - expect(screen.getByRole("columnheader", { name: /name/ })).toBeInTheDocument(); - expect(screen.getByRole("columnheader", { name: /email/ })).toBeInTheDocument(); - expect(screen.getByText("1")).toBeInTheDocument(); - expect(screen.getByText("Alice")).toBeInTheDocument(); - expect(screen.getByText("alice@example.com")).toBeInTheDocument(); - expect(screen.getByText("Bob")).toBeInTheDocument(); - expect(screen.getByText("NULL")).toBeInTheDocument(); - }); - - it("renders NULL values as italic muted text", () => { - useDbViewerStore.getState().openTab("public", "users"); - const tabId = useDbViewerStore.getState().tabs[0].id; - useDbViewerStore.getState().setTabData(tabId, mockData); - - render( {}} />); - const nullCell = screen.getByText("NULL"); - expect(nullCell).toHaveClass("italic"); - expect(nullCell).toHaveClass("text-text-muted"); - }); -}); \ No newline at end of file diff --git a/src/components/db-viewer/DataGrid.tsx b/src/components/db-viewer/DataGrid.tsx deleted file mode 100644 index 636763c..0000000 --- a/src/components/db-viewer/DataGrid.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { Key, Braces } from "lucide-react"; -import { useDbViewerStore } from "../../stores/dbViewerStore"; -import { abbreviateType } from "../../lib/utils"; -import { FkPreviewPopover } from "./FkPreviewPopover"; -import { JsonCellPopover, jsonPreview } from "./JsonCellPopover"; - -// TODO: Replace this plain HTML table with @tanstack/react-virtual for large -// result sets so we can render millions of rows without DOM overhead. - -type ColumnWidths = Record; -type TabColumnWidths = Record; - -const DEFAULT_COL_WIDTH = 200; -const MIN_COL_WIDTH = 60; -const MAX_COL_WIDTH = 800; -const CHECKBOX_COL_WIDTH = 40; - -interface DataGridProps { - connectionId: string; - rows: unknown[][]; - hiddenColumns?: Set; - selectedRows: Set; - onSelectionChange: (selected: Set) => void; -} - -export function DataGrid({ connectionId, rows, hiddenColumns, selectedRows, onSelectionChange }: DataGridProps) { - const tabs = useDbViewerStore((state) => state.tabs); - const activeTabId = useDbViewerStore((state) => state.activeTabId); - const [colWidths, setColWidths] = useState({}); - - // FK preview popover state - const [fkPreview, setFkPreview] = useState<{ - connectionId: string; - schema: string; - table: string; - column: string; - value: string; - anchorRect: DOMRect | null; - } | null>(null); - - // JSON cell popover state - const [jsonPopover, setJsonPopover] = useState<{ - value: unknown; - anchorRect: DOMRect | null; - } | null>(null); - - // ── helpers ──────────────────────────────────────────── - - const activeTab = activeTabId ? tabs.find((t) => t.id === activeTabId) : null; - const widths = activeTabId ? (colWidths[activeTabId] ?? {}) : {}; - - const getWidth = useCallback( - (colName: string) => widths[colName] ?? DEFAULT_COL_WIDTH, - [widths], - ); - - // ── selection logic ──────────────────────────────────── - - const allSelected = rows.length > 0 && selectedRows.size === rows.length; - const someSelected = selectedRows.size > 0 && selectedRows.size < rows.length; - const checkboxRef = useRef(null); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = someSelected; - } - }, [someSelected]); - - const toggleAll = () => { - if (allSelected) { - onSelectionChange(new Set()); - } else { - onSelectionChange(new Set(rows.map((_, i) => i))); - } - }; - - const toggleRow = (rowIndex: number) => { - const next = new Set(selectedRows); - if (next.has(rowIndex)) next.delete(rowIndex); - else next.add(rowIndex); - onSelectionChange(next); - }; - - // ── resize handler (ref-based to avoid stale closures) ─ - - 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) => { - if (!resizeRef.current) return; - const delta = ev.clientX - resizeRef.current.startX; - const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, resizeRef.current.startWidth + delta)); - setColWidths((prev) => ({ - ...prev, - [activeTabId!]: { ...(prev[activeTabId!] ?? {}), [resizeRef.current!.col]: next }, - })); - }; - - const onUp = () => { - resizeRef.current = null; - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - }; - - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - }, - [activeTabId, getWidth], - ); - - // ── FK row-click handler ─────────────────────────────── - - const handleFkClick = useCallback( - (col: { name: string; is_fk: boolean; fk_ref: [string, string] | null }, cellValue: unknown, e: React.MouseEvent) => { - if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return; - const [refTable] = col.fk_ref; - const schema = activeTab?.schema ?? "public"; - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setFkPreview({ - connectionId, - schema, - table: refTable, - column: col.fk_ref[1], - value: String(cellValue), - anchorRect: rect, - }); - }, - [activeTab], - ); - - // ── empty / loading / error states ───────────────────── - - if (!activeTabId) { - return ( -
- Select a table to view data -
- ); - } - - if (!activeTab) { - return ( -
- Select a table to view data -
- ); - } - - if (activeTab.loading && !activeTab.data) { - return ( -
- Loading... -
- ); - } - - if (activeTab.error) { - return ( -
- {activeTab.error} -
- ); - } - - if (!activeTab.data) { - return ( -
- Loading table data... -
- ); - } - - const { columns } = activeTab.data; - - // Filter visible columns - const visibleColumns = hiddenColumns - ? columns.filter((c) => !hiddenColumns.has(c.name)) - : columns; - - return ( -
- {/* Loading indicator bar when refreshing with existing data */} - {activeTab.loading && ( -
- )} - - - {/* Checkbox column */} - - {visibleColumns.map((col) => ( - - ))} - - - - {/* Header checkbox */} - - {visibleColumns.map((col) => ( - - ))} - - - - {rows.map((row, rowIndex) => { - const isSelected = selectedRows.has(rowIndex); - return ( - - {/* Row checkbox */} - - {visibleColumns.map((col) => { - 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 ( - - ); - })} - - ); - })} - -
-
- -
-
-
- {col.is_pk && } - {col.is_fk && } - {col.name} - - {abbreviateType(col.data_type)} - -
- {/* resize handle */} -
startResize(col.name, e)} - onDoubleClick={() => { - setColWidths((prev) => ({ - ...prev, - [activeTabId!]: { ...(prev[activeTabId!] ?? {}), [col.name]: DEFAULT_COL_WIDTH }, - })); - }} - /> -
-
- toggleRow(rowIndex)} - className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent" - /> -
-
-
handleFkClick(col!, cell, e) : isJson ? handleJsonClick : undefined} - role={isFk || isJson ? "button" : undefined} - tabIndex={isFk || isJson ? 0 : undefined} - onKeyDown={isFk ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleFkClick(col!, cell, e as any); } } : isJson ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleJsonClick(e as any); } } : undefined} - > - {isNull ? ( - NULL - ) : isJson ? ( - - - {jp.label} - - ) : ( - String(cell) - )} -
-
- {/* FK preview popover */} - {fkPreview && ( - setFkPreview(null)} - /> - )} - {/* JSON cell popover */} - {jsonPopover && ( - setJsonPopover(null)} - /> - )} -
- ); -} \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerScreen.test.tsx b/src/components/db-viewer/DbViewerScreen.test.tsx index 1d3d1b0..a97cf6e 100644 --- a/src/components/db-viewer/DbViewerScreen.test.tsx +++ b/src/components/db-viewer/DbViewerScreen.test.tsx @@ -1,8 +1,16 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { DbViewerScreen } from "./DbViewerScreen"; import { useDbViewerStore } from "../../stores/dbViewerStore"; +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: () => ({ + getVirtualItems: () => [], + getTotalSize: () => 0, + measureElement: () => {}, + }), +})); + describe("DbViewerScreen", () => { beforeEach(() => { useDbViewerStore.setState({ diff --git a/src/components/db-viewer/DbViewerScreen.tsx b/src/components/db-viewer/DbViewerScreen.tsx index 37fc2fa..2f9665f 100644 --- a/src/components/db-viewer/DbViewerScreen.tsx +++ b/src/components/db-viewer/DbViewerScreen.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { TooltipProvider } from "../ui/Tooltip"; import { DbViewerSidebar } from "./DbViewerSidebar"; import { DbViewerToolbar } from "./DbViewerToolbar"; import { TableTree } from "./TableTree"; +import { ObjectExplorerPage } from "./ObjectExplorerPage"; import { TabBar } from "./TabBar"; -import { DataGrid } from "./DataGrid"; +import { VirtualDataGrid } from "../grid/VirtualDataGrid"; import { ChangesQueuePanel } from "./ChangesQueuePanel"; import { TableControls } from "./TableControls"; import { EditConnectionModal } from "./EditConnectionModal"; @@ -14,395 +15,582 @@ import { useConnectionStore } from "../../stores/connectionStore"; import { useSettingsStore } from "../../stores/settingsStore"; import { useShortcut } from "../../hooks/useShortcut"; import { ConnectionDropBanner } from "./ConnectionDropBanner"; +import { BackupPage } from "./BackupPage"; +import { RestorePage } from "./RestorePage"; +import { SyncPage } from "./SyncPage"; import * as cmd from "../../lib/commands"; -import type { ColumnInfo } from "../../lib/types"; export interface DbViewerScreenProps { - connectionId: string; - onHome: () => void; - onSettings: () => void; + connectionId: string; + onHome: () => void; + onSettings: () => void; } -// ─── client-side filter/sort helpers ───────────────────── +export function DbViewerScreen({ + connectionId, + onHome, + onSettings, +}: DbViewerScreenProps) { + const { connectionError, connect } = useDbConnection(connectionId); + const [dismissedError, setDismissedError] = useState(null); + const [currentView, setCurrentView] = useState("db-viewer"); + const [tablePanelWidth, setTablePanelWidth] = useState(280); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedRows, setSelectedRows] = useState>(new Set()); + const [editModalOpen, setEditModalOpen] = useState(false); + const connections = useConnectionStore((s) => s.connections); + const currentConnection = + connections.find((c) => c.id === connectionId) ?? null; + const settings = useSettingsStore((s) => s.settings); + const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize); + const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter); + const setFilterRules = useDbViewerStore((s) => s.setFilterRules); + const setSortRules = useDbViewerStore((s) => s.setSortRules); + const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn); + const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied); -type FilterRule = { - id: string; - column: string; - operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull"; - value: string; -}; - -type SortRule = { id: string; column: string; order: "asc" | "desc" }; - -function applyFilters(rows: unknown[][], columns: ColumnInfo[], rules: FilterRule[]): unknown[][] { - if (rules.length === 0) return rows; - return rows.filter((row) => - rules.every((rule) => { - const ci = columns.findIndex((c) => c.name === rule.column); - if (ci < 0) return true; - const cell = row[ci]; - const str = cell === null || cell === undefined ? "" : String(cell); - switch (rule.operator) { - case "null": return cell === null; - case "notnull": return cell !== null; - case "eq": return str === rule.value; - case "neq": return str !== rule.value; - case "contains": return str.toLowerCase().includes(rule.value.toLowerCase()); - case "starts": return str.toLowerCase().startsWith(rule.value.toLowerCase()); - case "ends": return str.toLowerCase().endsWith(rule.value.toLowerCase()); - case "gt": return Number(str) > Number(rule.value); - case "lt": return Number(str) < Number(rule.value); - default: return true; - } - }), - ); -} - -function applySorts(rows: unknown[][], columns: ColumnInfo[], rules: SortRule[]): unknown[][] { - if (rules.length === 0) return rows; - return [...rows].sort((a, b) => { - for (const rule of rules) { - const ci = columns.findIndex((c) => c.name === rule.column); - if (ci < 0) continue; - const va = a[ci]; - const vb = b[ci]; - const cmp = - va === null && vb === null ? 0 - : va === null ? -1 - : vb === null ? 1 - : String(va).localeCompare(String(vb), undefined, { numeric: true }); - if (cmp !== 0) return rule.order === "asc" ? cmp : -cmp; - } - return 0; - }); -} - -export function DbViewerScreen({ connectionId, onHome, onSettings }: DbViewerScreenProps) { - const { connectionError, connect } = useDbConnection(connectionId); - const [dismissedError, setDismissedError] = useState(null); - const [tablePanelWidth, setTablePanelWidth] = useState(280); - const [hiddenColumns, setHiddenColumns] = useState>(new Set()); - const [filterRules, setFilterRules] = useState([]); - const [sortRules, setSortRules] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const smartSortApplied = useRef>(new Set()); - const [selectedRows, setSelectedRows] = useState>(new Set()); - const [editModalOpen, setEditModalOpen] = useState(false); - const connections = useConnectionStore((s) => s.connections); - const currentConnection = connections.find((c) => c.id === connectionId) ?? null; - const settings = useSettingsStore((s) => s.settings); - const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize); - const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter); - - // Sync settings defaults to store - useEffect(() => { - if (settings?.table_page_size) { - setDefaultPageSize(settings.table_page_size); - } - }, [settings?.table_page_size, setDefaultPageSize]); - const panelResizeRef = useRef<{ startX: number; startW: number } | null>(null); - - const activeTab = useDbViewerStore((s) => { - if (!s.activeTabId) return null; - return s.tabs.find((t) => t.id === s.activeTabId) ?? null; - }); - const setTabData = useDbViewerStore((s) => s.setTabData); - const setTabError = useDbViewerStore((s) => s.setTabError); - 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); - const fetchingRef = useRef>(new Set()); - - const fetchData = useCallback(async (tab: NonNullable) => { - if (fetchingRef.current.has(tab.id)) return; - fetchingRef.current.add(tab.id); - try { - const result = await cmd.getTableData( - connectionId, - tab.schema, - tab.table, - tab.page, - tab.pageSize, - ); - setTabData(tab.id, result); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setTabError(tab.id, msg); - } finally { - fetchingRef.current.delete(tab.id); - } - }, [connectionId, setTabData, setTabError]); - - // Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts) - useShortcut("close_tab", () => { - const state = useDbViewerStore.getState(); - if (state.activeTabId) { - state.closeTab(state.activeTabId); - } else { - onHome(); - } - }); - useEffect(() => { - if (!activeTab) return; - if (!activeTab.loading) return; - if (activeTab.error) return; - fetchData(activeTab); - }, [activeTab, fetchData]); - - // Smart default sort: apply once when data first loads for a tab - useEffect(() => { - if (!activeTab) return; - if (activeTab.loading) return; - if (!activeTab.data) return; - if (smartSortApplied.current.has(activeTab.id)) return; - - const cols = activeTab.data.columns; - - 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; + // Sync settings defaults to store + useEffect(() => { + if (settings?.table_page_size) { + setDefaultPageSize(settings.table_page_size); } - } - 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; - }; + }, [settings?.table_page_size, setDefaultPageSize]); + const panelResizeRef = useRef<{ startX: number; startW: number } | null>( + null, + ); - // 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) { - smartSortApplied.current.add(activeTab.id); - setSortRules([{ 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; - setFilterRules((prev) => { - const exists = prev.some((r) => r.column === column && r.value === value); - if (exists) return prev; - return [...prev, { id: crypto.randomUUID(), column, operator: "contains" as const, value }]; + const activeTab = useDbViewerStore((s) => { + if (!s.activeTabId) return null; + return s.tabs.find((t) => t.id === s.activeTabId) ?? null; }); - }, [activeTab?.columnFilter]); - // 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]); + // Derive per-tab toolbar state from active tab + const filterRules = activeTab?.filterRules ?? []; + const sortRules = activeTab?.sortRules ?? []; + const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []); - // 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 setTabData = useDbViewerStore((s) => s.setTabData); + const setTabError = useDbViewerStore((s) => s.setTabError); + 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); + const fetchingRef = useRef>(new Set()); - const rawRows = activeTab?.data?.rows ?? []; - const columns = activeTab?.data?.columns ?? []; - const processedRows = useMemo(() => { - let result = rawRows; - result = applyFilters(result, columns, filterRules); - result = applySorts(result, columns, sortRules); - return result; - }, [rawRows, columns, filterRules, sortRules]); + const fetchData = useCallback( + async (tab: NonNullable) => { + if (fetchingRef.current.has(tab.id)) return; + fetchingRef.current.add(tab.id); + try { + const result = await cmd.getTableData( + connectionId, + tab.schema, + tab.table, + tab.page, + tab.pageSize, + tab.filterRules, + tab.sortRules, + ); + setTabData(tab.id, result); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setTabError(tab.id, msg); + } finally { + fetchingRef.current.delete(tab.id); + } + }, + [connectionId, setTabData, setTabError], + ); - 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]); + // Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts) + useShortcut("close_tab", () => { + const state = useDbViewerStore.getState(); + if (state.activeTabId) { + state.closeTab(state.activeTabId); + } else { + onHome(); + } + }); + useEffect(() => { + if (!activeTab) return; + if (!activeTab.loading) return; + if (activeTab.error) return; + fetchData(activeTab); + }, [activeTab, fetchData]); - const handleNavigate = useCallback( - (view: string) => { - if (view === "home") onHome(); - else if (view === "settings") onSettings(); - }, - [onHome, onSettings], - ); + // Smart default sort: apply once when data first loads for a tab + useEffect(() => { + if (!activeTab) return; + if (activeTab.loading) return; + if (!activeTab.data) return; + if (activeTab.smartSortApplied) return; - const activeSchema = activeTab?.schema ?? ""; - const activeTable = activeTab?.table ?? ""; + const cols = activeTab.data.columns; - return ( - -
- -
- {connectionError && connectionError !== dismissedError && ( - { - setDismissedError(null); - connect(); - }} - onDismiss={() => setDismissedError(connectionError)} - /> - )} -
-
- 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 */} +
- {refreshing ? ( - - ) : result === 'success' ? ( - - ) : result === 'error' ? ( - - ) : ( - - )} - - - - - - - - +
+ + 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 && ( + + )} +
+
+ {(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" + /> + )} +
+ )}
-
- {/* 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 && ( - - )} -
-
- {(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 && ( +
+ +
+ )} + {expanded && totalLines > maxLines && ( +
+ +
+ )} +
+ ); +} + +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 && ( + + )} +
+
+ + {/* 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 && ( + + )} +
+ ); +} + +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)} + > + + {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" + /> + +
+
+ + {/* 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 */} +
+ + +
+
+ )} +
+
+ ); +} + +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 */} +
+ +
+ + )} +
+
+
+ ); +} + 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 */} +
+ + +
+
+ )} +
+
+ ); +} + +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 */} +
+ +
+ + )} +
+
+
+ ); +} + 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(" ")} > - + - - - {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)} + > + + + + {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 (
- {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 (
- {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 }),