diff --git a/README.md b/README.md index e2d4728..e53db9d 100644 --- a/README.md +++ b/README.md @@ -103,17 +103,19 @@ For a comprehensive breakdown of all features, see the [Feature Reference](FEATU - pnpm - Rust toolchain (for Tauri) +RelWave uses separate package manifests for the app and the bridge, so install dependencies in both locations. + #### Development ```bash git clone https://github.com/Relwave/relwave-app.git cd relwave-app -# Install frontend dependencies +# Install app dependencies pnpm install # Install bridge dependencies -cd bridge && pnpm install && cd .. +pnpm --dir bridge install # Start development mode pnpm tauri dev @@ -121,10 +123,17 @@ pnpm tauri dev #### Production Build +Install both dependency sets first: + +```bash +pnpm install +pnpm --dir bridge install +``` + **Windows:** ```bash -pnpm run package-bridge +pnpm run bridge:package pnpm tauri build ``` @@ -133,10 +142,12 @@ pnpm tauri build ```bash sudo apt install libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf -pnpm --dir bridge run build:pkg:linux +pnpm run bridge:package pnpm tauri build ``` +The root `bridge:package` script delegates to the platform-specific bridge packaging command in `bridge/package.json` and prepares the bundled SQLite native binary in `src-tauri/resources/`. + ## Documentation ### Architecture @@ -206,7 +217,7 @@ relwave-app/ └── src-tauri/ # Tauri backend (Rust) ├── src/ # Application entry point ├── capabilities/ # Permission definitions - └── resources/ # Bundled bridge executable + └── resources/ # Bundled bridge executable and SQLite native binding ``` ### Configuration diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/chart/ChartVisualization.tsx b/src/components/chart/ChartVisualization.tsx deleted file mode 100644 index 04b93a3..0000000 --- a/src/components/chart/ChartVisualization.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Loader2, BarChart3, Download, ChevronDown } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { toPng, toSvg } from "html-to-image"; -import { toast } from "sonner"; -import { ChartConfigPanel } from "./ChartConfigPanel"; -import ChartRenderer from "./ChartRenderer"; -import { ColumnDetails, SelectedTable } from "@/types/database"; -import { bridgeApi } from "@/services/bridgeApi"; - -interface ChartVisualizationProps { - selectedTable: SelectedTable; - dbId?: string; -} - -interface QueryResultRow { - count: string; -} - -interface QueryResultColumn { - name: string -} - -export interface QueryResultEventDetail { - sessionId: string; - batchIndex: number; - rows: QueryResultRow[]; - columns: QueryResultColumn[]; - completed: boolean; -} - -export const ChartVisualization = ({ selectedTable, dbId }: ChartVisualizationProps) => { - - const [chartType, setChartType] = useState<"bar" | "line" | "pie" | "scatter">("bar"); - const [xAxis, setXAxis] = useState(""); - const [yAxis, setYAxis] = useState(""); - const [chartTitle, setChartTitle] = useState("Query Results Visualization"); - const [columnData, setColumnData] = useState([]); - const [schemaData, setSchemaData] = useState(null); - const [rowData, setRowData] = useState([]); - const [querySessionId, setQuerySessionId] = useState(null); - const [isExecuting, setIsExecuting] = useState(false); - const [queryProgress, setQueryProgress] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - - - const handleExport = async (format: "png" | "svg") => { - const chartElement = document.getElementById("chart-container"); - if (!chartElement) return; - - try { - // Determine background based on current theme (simple detection based on dark class presence) - const isDarkMode = chartElement.closest('.dark'); - const backgroundColor = isDarkMode ? "#050505" : "#FFFFFF"; // Use app background - - const dataUrl = format === "png" - ? await toPng(chartElement, { quality: 0.95, backgroundColor }) - : await toSvg(chartElement, { backgroundColor }); - - const link = document.createElement("a"); - link.download = `chart-${Date.now()}.${format}`; - link.href = dataUrl; - link.click(); - - toast.success(`Chart exported as ${format.toUpperCase()}`); - } catch (error) { - toast.error("Failed to export chart"); - setErrorMessage("Failed to export chart"); - } - }; - - useEffect(() => { - async function getTables() { - if (dbId) { - try { - const result = await bridgeApi.getSchema(dbId); - const schemas = result?.schemas - - schemas?.map((schema) => { - if (schema.name === selectedTable.schema) { - schema.tables.map((table) => { - if (table.name === selectedTable.name) { - setColumnData(table.columns); - } - }) - } - }); - } catch (error) { - toast.error("Failed to fetch table schema"); - setErrorMessage("Failed to fetch table schema"); - } - } - } - - - getTables(); - }, [selectedTable, dbId]); - - // Execute query when x or y axis changes - useEffect(() => { - if (!xAxis || !yAxis) return; - - const executeQuery = async () => { - // Clear old data immediately when config changes - setRowData([]); - setIsExecuting(true); - setErrorMessage(null); - - try { - const sessionId = `chart-${Date.now()}`; - setQuerySessionId(sessionId); - - // X-axis: grouping dimension (non-primary keys like address, name) - // Y-axis: what we're counting (primary keys like id) - const sql = ` - SELECT "${xAxis}" as name, COUNT("${yAxis}") as count - FROM "${selectedTable.schema}"."${selectedTable.name}" - GROUP BY "${xAxis}" - ORDER BY count DESC - LIMIT 50 - `; - - await bridgeApi.runQuery({ - sessionId, - dbId: dbId || "", - sql: sql.trim(), - batchSize: 50, - }); - - // Query execution started successfully - } catch (err: any) { - console.error("Query execution error:", err); - setErrorMessage(err.message || "Failed to execute query"); - setIsExecuting(false); - setRowData([]); // Clear data on error - } - }; - - executeQuery(); - }, [xAxis, yAxis, selectedTable, dbId]); - - useEffect(() => { - const handleResult = (event: CustomEvent) => { - if (event.detail.sessionId !== querySessionId) return; - setSchemaData(event.detail); - // Replace data instead of appending to prevent accumulation - setRowData(event.detail.rows); - setIsExecuting(false); - }; - - const handleError = (event: CustomEvent) => { - if (event.detail.sessionId !== querySessionId) return; - - setIsExecuting(false); - setQuerySessionId(null); - setQueryProgress(null); - toast.error("Query failed", { description: event.detail.error?.message || "An error occurred" }); - }; - - const eventListeners = [ - { name: 'bridge:query.result', handler: handleResult }, - { name: 'bridge:query.error', handler: handleError }, - ]; - - eventListeners.forEach(listener => { - window.addEventListener(listener.name, listener.handler as EventListener); - }); - - return () => { - eventListeners.forEach(listener => { - window.removeEventListener(listener.name, listener.handler as EventListener); - }); - }; - }, [querySessionId]); - - - return ( - - {/* Config Panel */} - - - - - - - Configure Chart - - - - - - - Export - - - - - handleExport("png")} className="text-xs"> - Export as PNG - - handleExport("svg")} className="text-xs"> - Export as SVG - - - - - - - - - {/* Chart Container */} - - {chartTitle && ( - - {chartTitle} - - )} - - {isExecuting ? ( - - - Processing data... - - ) : errorMessage ? ( - - - - - {errorMessage} - - ) : !rowData.length ? ( - - - - - Select axes to visualize data - - ) : ( - - )} - - - ); -}; \ No newline at end of file diff --git a/src/components/common/DeveloperContextMenu.tsx b/src/components/common/DeveloperContextMenu.tsx deleted file mode 100644 index 89f41b5..0000000 --- a/src/components/common/DeveloperContextMenu.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useEffect, useState, useCallback } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, - ContextMenuShortcut, -} from "@/components/ui/context-menu"; -import { - ArrowLeft, - ArrowRight, - RefreshCw, - Bug, - Copy, - ClipboardPaste, - Scissors, -} from "lucide-react"; -import { getDeveloperMode } from "@/hooks/useDeveloperMode"; - -interface DeveloperContextMenuProps { - children: React.ReactNode; -} - -export function DeveloperContextMenu({ children }: DeveloperContextMenuProps) { - const [devMode, setDevMode] = useState(getDeveloperMode); - - // Listen for developer mode changes - useEffect(() => { - const handleChange = (e: CustomEvent<{ enabled: boolean }>) => { - setDevMode(e.detail.enabled); - }; - - window.addEventListener("developer-mode-change", handleChange as EventListener); - return () => { - window.removeEventListener("developer-mode-change", handleChange as EventListener); - }; - }, []); - - // Block dev tools keyboard shortcuts when dev mode is disabled - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (devMode) return; // Allow shortcuts when dev mode is enabled - - // Block Ctrl+Shift+I, Ctrl+Shift+J, Ctrl+Shift+C, F12 - if ( - (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.key === 'i' || e.key === 'J' || e.key === 'j' || e.key === 'C' || e.key === 'c')) || - e.key === 'F12' - ) { - e.preventDefault(); - e.stopPropagation(); - } - }; - - window.addEventListener('keydown', handleKeyDown, true); - return () => { - window.removeEventListener('keydown', handleKeyDown, true); - }; - }, [devMode]); - - const handleReload = useCallback(async () => { - try { - await invoke("reload_webview"); - } catch (e) { - console.error("Failed to reload:", e); - window.location.reload(); - } - }, []); - - const handleBack = useCallback(async () => { - try { - await invoke("navigate_back"); - } catch (e) { - console.error("Failed to navigate back:", e); - window.history.back(); - } - }, []); - - const handleForward = useCallback(async () => { - try { - await invoke("navigate_forward"); - } catch (e) { - console.error("Failed to navigate forward:", e); - window.history.forward(); - } - }, []); - - const handleInspect = useCallback(async () => { - try { - await invoke("open_devtools"); - } catch (e) { - console.error("Failed to open devtools:", e); - } - }, []); - - const handleCopy = useCallback(() => { - document.execCommand("copy"); - }, []); - - const handleCut = useCallback(() => { - document.execCommand("cut"); - }, []); - - const handlePaste = useCallback(async () => { - try { - const text = await navigator.clipboard.readText(); - document.execCommand("insertText", false, text); - } catch (e) { - document.execCommand("paste"); - } - }, []); - - // If developer mode is disabled, block all context menus - if (!devMode) { - return ( - e.preventDefault()} - > - {children} - - ); - } - - return ( - - - {children} - - - - - Back - Alt+← - - - - Forward - Alt+→ - - - - Reload - Ctrl+R - - - - - Inspect Element - F12 - - - - ); -} diff --git a/src/components/common/ModeToggle.tsx b/src/components/common/ModeToggle.tsx deleted file mode 100644 index 4312c7b..0000000 --- a/src/components/common/ModeToggle.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Moon, Sun } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { useTheme } from "@/components/common/ThemeProvider" - -export function ModeToggle() { - const { setTheme } = useTheme() - - return ( - - - - - - Toggle theme - - - - setTheme("light")}> - Light - - setTheme("dark")}> - Dark - - setTheme("system")}> - System - - - - ) -} \ No newline at end of file diff --git a/src/components/common/index.ts b/src/components/common/index.ts deleted file mode 100644 index 8972c75..0000000 --- a/src/components/common/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { DataTable } from './DataTable' -export { ModeToggle } from './ModeToggle' -export { ThemeProvider, useTheme } from './ThemeProvider' diff --git a/src/components/dev/DeveloperContextMenu.tsx b/src/components/dev/DeveloperContextMenu.tsx new file mode 100644 index 0000000..32a7d36 --- /dev/null +++ b/src/components/dev/DeveloperContextMenu.tsx @@ -0,0 +1,61 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + ContextMenuShortcut, +} from "@/components/ui/context-menu"; +import { ArrowLeft, ArrowRight, RefreshCw, Bug } from "lucide-react"; +import { useDevMode } from "@/features/settings/hooks/useDevMode"; +import { useDevModeKeyboard } from "@/features/settings/hooks/useDevModeKeyboard"; +import { useWebviewActions } from "@/features/settings/hooks/useWebviewActions"; + +interface DeveloperContextMenuProps { + children: React.ReactNode; +} + +export const DeveloperContextMenu = ({ children }: DeveloperContextMenuProps) => { + const devMode = useDevMode(); + useDevModeKeyboard(devMode); + const { reload, goBack, goForward, openDevtools } = useWebviewActions(); + + if (!devMode) { + return ( + e.preventDefault()}> + {children} + + ); + } + + return ( + + + {children} + + + + + Back + Alt+← + + + + Forward + Alt+→ + + + + Reload + Ctrl+R + + + + + Inspect Element + F12 + + + + ); +}; \ No newline at end of file diff --git a/src/components/common/RemoteConfigDialog.tsx b/src/components/dev/RemoteConfigDialog.tsx similarity index 98% rename from src/components/common/RemoteConfigDialog.tsx rename to src/components/dev/RemoteConfigDialog.tsx index 11da2f7..c504a10 100644 --- a/src/components/common/RemoteConfigDialog.tsx +++ b/src/components/dev/RemoteConfigDialog.tsx @@ -23,9 +23,9 @@ import { useGitRemoteAdd, useGitRemoteRemove, useGitRemoteSetUrl, -} from "@/hooks/useGitAdvanced"; +} from "@/features/git/hooks/useGitAdvanced"; import { toast } from "sonner"; -import type { GitRemoteInfo } from "@/types/git"; +import type { GitRemoteInfo } from "@/features/git/types"; interface RemoteConfigDialogProps { open: boolean; @@ -95,7 +95,7 @@ export default function RemoteConfigDialog({ - + {isLoading && ( diff --git a/src/components/feedback/index.ts b/src/components/feedback/index.ts deleted file mode 100644 index e361719..0000000 --- a/src/components/feedback/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as BridgeFailed } from './BridgeFailed' -export { default as BridgeLoader } from './BridgeLoader' diff --git a/src/components/common/SlideOutPanel.tsx b/src/components/layout/SlideOutPanel.tsx similarity index 100% rename from src/components/common/SlideOutPanel.tsx rename to src/components/layout/SlideOutPanel.tsx diff --git a/src/components/common/TitleBar.tsx b/src/components/layout/TitleBar.tsx similarity index 100% rename from src/components/common/TitleBar.tsx rename to src/components/layout/TitleBar.tsx diff --git a/src/components/common/VerticalIconBar.tsx b/src/components/layout/VerticalIconBar.tsx similarity index 98% rename from src/components/common/VerticalIconBar.tsx rename to src/components/layout/VerticalIconBar.tsx index 3441ea8..58c3aed 100644 --- a/src/components/common/VerticalIconBar.tsx +++ b/src/components/layout/VerticalIconBar.tsx @@ -42,7 +42,7 @@ export default function VerticalIconBar({ dbId, activePanel, onPanelChange }: Ve ] : []; return ( - + {/* Logo/Brand */} diff --git a/src/components/common/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx similarity index 100% rename from src/components/common/ThemeProvider.tsx rename to src/components/providers/ThemeProvider.tsx diff --git a/src/components/schema-explorer/TreeViewPanel.tsx b/src/components/schema-explorer/TreeViewPanel.tsx deleted file mode 100644 index 182d2fd..0000000 --- a/src/components/schema-explorer/TreeViewPanel.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import { ScrollArea } from '@/components/ui/scroll-area'; -import { ChevronDown, ChevronRight, Copy, Database, Download, Eye, FileCode, Hash, Key, Layers, Link2, List, Table } from 'lucide-react'; -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; -import { Badge } from "@/components/ui/badge"; -import { ForeignKeyInfo } from '@/types/database'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Column, DatabaseSchema, TreeViewPanelProps } from './types'; - -// Helper to get FK info for a column -const getFkInfo = (column: Column, foreignKeys?: ForeignKeyInfo[]): ForeignKeyInfo | undefined => { - return foreignKeys?.find(fk => fk.source_column === column.name); -}; - -const TreeViewPanel = ({ database, expandedSchemas, expandedTables, toggleSchema, toggleTable, selectedItem, setSelectedItem, handlePreviewRows, handleShowDDL, handleCopy, handleExport }: TreeViewPanelProps) => { - return ( - - - - - {/* Database Level (Root) */} - - setSelectedItem(database.name)} - > - - {database.name} - - {database.schemas.length} schemas - - - - {/* Schema Level */} - - {database.schemas.map((schema) => { - const tableCount = schema.tables.length; - const enumCount = schema.enumTypes?.length || 0; - const seqCount = schema.sequences?.length || 0; - - return ( - - { - toggleSchema(schema.name); - setSelectedItem(`${database.name}:::${schema.name}`); - }} - > - {expandedSchemas.has(schema.name) ? ( - - ) : ( - - )} - - {schema.name} - - {tableCount} - {enumCount > 0 && ( - - - - {enumCount} E - - - Enum Types - - )} - {seqCount > 0 && ( - - - - {seqCount} S - - - Sequences - - )} - - - - {/* Schema Contents (Tables, Enums, Sequences) */} - {expandedSchemas.has(schema.name) && ( - - {/* Tables */} - {schema.tables.map((table) => { - const fkCount = table.foreignKeys?.length || 0; - const idxCount = table.indexes?.length || 0; - const chkCount = table.checkConstraints?.length || 0; - - return ( - - - - { - toggleTable(table.name); - setSelectedItem(`${database.name}:::${schema.name}:::${table.name}`); - }} - > - {expandedTables.has(table.name) ? ( - - ) : ( - - )} - - {table.name} - - {table.type !== "BASE TABLE" && ( - - VIEW - - )} - {fkCount > 0 && ( - - - - {fkCount} FK - - - Foreign Keys - - )} - - - - {/* Column Level */} - {expandedTables.has(table.name) && ( - - {table.columns.map((column) => { - const fkInfo = getFkInfo(column, table.foreignKeys); - const isIndexed = table.indexes?.some(idx => idx.column_name === column.name && !idx.is_primary); - const isUnique = table.uniqueConstraints?.some(uc => uc.column_name === column.name); - - return ( - - - - - - - setSelectedItem(`${database.name}:::${schema.name}:::${table.name}:::${column.name}`) - } - > - - {column.isPrimaryKey && } - {column.isForeignKey && } - - {column.name} - - {isUnique && ( - - UQ - - )} - {isIndexed && ( - - IDX - - )} - - - {column.type} - {!column.nullable && ( - * - )} - - - - - - - {column.name} - Type: {column.type} - Nullable: {column.nullable ? 'Yes' : 'No'} - {column.defaultValue && Default: {column.defaultValue}} - {fkInfo && ( - - → {fkInfo.target_table}.{fkInfo.target_column} - - )} - - - - - - handleCopy(column.name, "Column name")} className="hover:bg-accent"> - Copy Column Name - - handleCopy(column.type, "Column type")} className="hover:bg-accent"> - Copy Type - - - - ); - })} - - )} - - - - handlePreviewRows(table.name)} className="hover:bg-accent"> - Preview Rows - - handleShowDDL(table.name)} className="hover:bg-accent"> - Show DDL - - handleCopy(table.name, "Table name")} className="hover:bg-accent"> - Copy Table Name - - handleExport(table.name)} className="hover:bg-accent"> - Export Table - - - - ); - })} - - {/* Enum Types Section */} - {schema.enumTypes && schema.enumTypes.length > 0 && ( - - Enum Types - {/* Group enums by name */} - {Array.from(new Set(schema.enumTypes.map(e => e.enum_name))).map(enumName => { - const enumValues = schema.enumTypes?.filter(e => e.enum_name === enumName).map(e => e.enum_value) || []; - return ( - - - setSelectedItem(`${database.name}:::${schema.name}:::enum:::${enumName}`)} - > - - {enumName} - {enumValues.length} - - - - - Values: - {enumValues.map(v => ( - • {v} - ))} - - - - ); - })} - - )} - - {/* Sequences Section */} - {schema.sequences && schema.sequences.length > 0 && ( - - Sequences - {schema.sequences.map(seq => ( - - - setSelectedItem(`${database.name}:::${schema.name}:::seq:::${seq.sequence_name}`)} - > - - {seq.sequence_name} - - - - - {seq.table_name && seq.column_name ? ( - Used by: {seq.table_name}.{seq.column_name} - ) : ( - Standalone sequence - )} - - - - ))} - - )} - - )} - - ); - })} - - - - - - - ); -} - -export default TreeViewPanel \ No newline at end of file diff --git a/src/components/common/CommandPalette.tsx b/src/components/shared/CommandPalette.tsx similarity index 96% rename from src/components/common/CommandPalette.tsx rename to src/components/shared/CommandPalette.tsx index 15fab05..f0ea5cb 100644 --- a/src/components/common/CommandPalette.tsx +++ b/src/components/shared/CommandPalette.tsx @@ -71,7 +71,7 @@ export default function CommandPalette({ isOpen, onClose }: CommandPaletteProps) return ( - + - + {filteredCommands.length === 0 ? ( diff --git a/src/components/common/ConfirmDialog.tsx b/src/components/shared/ConfirmDialog.tsx similarity index 100% rename from src/components/common/ConfirmDialog.tsx rename to src/components/shared/ConfirmDialog.tsx diff --git a/src/components/common/DataTable.tsx b/src/components/shared/DataTable.tsx similarity index 99% rename from src/components/common/DataTable.tsx rename to src/components/shared/DataTable.tsx index a949ea0..d12e07a 100644 --- a/src/components/common/DataTable.tsx +++ b/src/components/shared/DataTable.tsx @@ -32,7 +32,7 @@ export const DataTable = ({ No results found - + This table is empty or the query returned no data @@ -80,7 +80,7 @@ export const DataTable = ({ {columns.map((column) => ( {row[column] !== null && row[column] !== undefined ? ( diff --git a/src/components/common/FloatingActionButton.tsx b/src/components/shared/FloatingActionButton.tsx similarity index 100% rename from src/components/common/FloatingActionButton.tsx rename to src/components/shared/FloatingActionButton.tsx diff --git a/src/components/common/UpdateNotification.tsx b/src/components/shared/UpdateNotification.tsx similarity index 97% rename from src/components/common/UpdateNotification.tsx rename to src/components/shared/UpdateNotification.tsx index 8d4c080..83bfbf6 100644 --- a/src/components/common/UpdateNotification.tsx +++ b/src/components/shared/UpdateNotification.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useUpdater } from "@/hooks/useUpdater"; +import { useUpdater } from "@/features/settings/hooks/useUpdater"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; @@ -155,7 +155,7 @@ export function UpdateCheckerButton() { size="sm" onClick={handleClick} disabled={status === "downloading" || status === "checking" || status === "dev-mode"} - className="min-w-[180px]" + className="min-w-45" > {getButtonContent()} diff --git a/src/components/chart/ChartConfigPanel.tsx b/src/features/chart/components/ChartConfigPanel.tsx similarity index 98% rename from src/components/chart/ChartConfigPanel.tsx rename to src/features/chart/components/ChartConfigPanel.tsx index 045a8fb..415d52a 100644 --- a/src/components/chart/ChartConfigPanel.tsx +++ b/src/features/chart/components/ChartConfigPanel.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; import { BarChart3, TrendingUp, PieChart, ScatterChart } from "lucide-react"; -import { ColumnDetails } from '@/types/database'; +import { ColumnDetails } from '@/features/database/types'; import { cn } from "@/lib/utils"; interface ChartConfigPanelProps { diff --git a/src/components/chart/ChartRenderer.tsx b/src/features/chart/components/ChartRenderer.tsx similarity index 94% rename from src/components/chart/ChartRenderer.tsx rename to src/features/chart/components/ChartRenderer.tsx index 3eb6efa..4bb54d6 100644 --- a/src/components/chart/ChartRenderer.tsx +++ b/src/features/chart/components/ChartRenderer.tsx @@ -59,7 +59,7 @@ const ChartRendererComponent = ({ if (!xAxis || chartData.length === 0) { return ( - + @@ -73,7 +73,7 @@ const ChartRendererComponent = ({ // Bar Chart if (chartType === "bar") { return ( - + + + } @@ -173,7 +173,7 @@ const ChartRendererComponent = ({ // Scatter Chart if (chartType === "scatter") { return ( - + { + + + + const { handleExport, chartType, chartTitle, setChartTitle, setChartType, xAxis, yAxis, setXAxis, setYAxis, columnData, isExecuting, errorMessage, rowData } = useChartVisualization(selectedTable, dbId); + + + + return ( + + {/* Config Panel */} + + + + + + + Configure Chart + + + + + + + Export + + + + + handleExport("png")} className="text-xs"> + Export as PNG + + handleExport("svg")} className="text-xs"> + Export as SVG + + + + + + + + + {/* Chart Container */} + + {chartTitle && ( + + {chartTitle} + + )} + + {isExecuting ? ( + + + Processing data... + + ) : errorMessage ? ( + + + + + {errorMessage} + + ) : !rowData.length ? ( + + + + + Select axes to visualize data + + ) : ( + + )} + + + ); +}; \ No newline at end of file diff --git a/src/components/chart/index.ts b/src/features/chart/components/index.ts similarity index 100% rename from src/components/chart/index.ts rename to src/features/chart/components/index.ts diff --git a/src/features/chart/hooks/useChartVisualization.ts b/src/features/chart/hooks/useChartVisualization.ts new file mode 100644 index 0000000..f61867e --- /dev/null +++ b/src/features/chart/hooks/useChartVisualization.ts @@ -0,0 +1,188 @@ +import { ColumnDetails, SelectedTable } from "@/features/database/types"; +import { databaseService } from "@/services/bridge/database"; +import { queryService } from "@/services/bridge/query"; +import { toPng, toSvg } from "html-to-image"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + + +interface QueryResultRow { + count: string; +} + +interface QueryResultColumn { + name: string +} + +export interface QueryResultEventDetail { + sessionId: string; + batchIndex: number; + rows: QueryResultRow[]; + columns: QueryResultColumn[]; + completed: boolean; +} + +export const useChartVisualization = (selectedTable: SelectedTable, dbId?: string) => { + + const [chartType, setChartType] = useState<"bar" | "line" | "pie" | "scatter">("bar"); + const [xAxis, setXAxis] = useState(""); + const [yAxis, setYAxis] = useState(""); + const [chartTitle, setChartTitle] = useState("Query Results Visualization"); + const [columnData, setColumnData] = useState([]); + const [schemaData, setSchemaData] = useState(null); + const [rowData, setRowData] = useState([]); + const [querySessionId, setQuerySessionId] = useState(null); + const [isExecuting, setIsExecuting] = useState(false); + const [queryProgress, setQueryProgress] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + + + const handleExport = async (format: "png" | "svg") => { + const chartElement = document.getElementById("chart-container"); + if (!chartElement) return; + + try { + // Determine background based on current theme (simple detection based on dark class presence) + const isDarkMode = chartElement.closest('.dark'); + const backgroundColor = isDarkMode ? "#050505" : "#FFFFFF"; // Use app background + + const dataUrl = format === "png" + ? await toPng(chartElement, { quality: 0.95, backgroundColor }) + : await toSvg(chartElement, { backgroundColor }); + + const link = document.createElement("a"); + link.download = `chart-${Date.now()}.${format}`; + link.href = dataUrl; + link.click(); + + toast.success(`Chart exported as ${format.toUpperCase()}`); + } catch (error) { + toast.error("Failed to export chart"); + setErrorMessage("Failed to export chart"); + } + } + + useEffect(() => { + async function getTables() { + if (dbId) { + try { + const result = await databaseService.getSchema(dbId); + const schemas = result?.schemas + + schemas?.map((schema) => { + if (schema.name === selectedTable?.schema) { + schema.tables.map((table) => { + if (table.name === selectedTable?.name) { + setColumnData(table.columns); + } + }) + } + }); + } catch (error) { + toast.error("Failed to fetch table schema"); + setErrorMessage("Failed to fetch table schema"); + } + } + } + + + getTables(); + }, [selectedTable, dbId]); + + // Execute query when x or y axis changes + useEffect(() => { + if (!xAxis || !yAxis) return; + + const executeQuery = async () => { + // Clear old data immediately when config changes + setRowData([]); + setIsExecuting(true); + setErrorMessage(null); + + try { + const sessionId = `chart-${Date.now()}`; + setQuerySessionId(sessionId); + + // X-axis: grouping dimension (non-primary keys like address, name) + // Y-axis: what we're counting (primary keys like id) + const sql = ` + SELECT "${xAxis}" as name, COUNT("${yAxis}") as count + FROM "${selectedTable?.schema}"."${selectedTable?.name}" + GROUP BY "${xAxis}" + ORDER BY count DESC + LIMIT 50 + `; + + await queryService.runQuery({ + sessionId, + dbId: dbId || "", + sql: sql.trim(), + batchSize: 50, + }); + + // Query execution started successfully + } catch (err: any) { + console.error("Query execution error:", err); + setErrorMessage(err.message || "Failed to execute query"); + setIsExecuting(false); + setRowData([]); // Clear data on error + } + }; + + executeQuery(); + }, [xAxis, yAxis, selectedTable, dbId]); + + useEffect(() => { + const handleResult = (event: CustomEvent) => { + if (event.detail.sessionId !== querySessionId) return; + setSchemaData(event.detail); + // Replace data instead of appending to prevent accumulation + setRowData(event.detail.rows); + setIsExecuting(false); + }; + + const handleError = (event: CustomEvent) => { + if (event.detail.sessionId !== querySessionId) return; + + setIsExecuting(false); + setQuerySessionId(null); + setQueryProgress(null); + toast.error("Query failed", { description: event.detail.error?.message || "An error occurred" }); + }; + + const eventListeners = [ + { name: 'bridge:query.result', handler: handleResult }, + { name: 'bridge:query.error', handler: handleError }, + ]; + + eventListeners.forEach(listener => { + window.addEventListener(listener.name, listener.handler as EventListener); + }); + + return () => { + eventListeners.forEach(listener => { + window.removeEventListener(listener.name, listener.handler as EventListener); + }); + }; + }, [querySessionId]); + + return { + handleExport, + setXAxis, + setYAxis, + setChartType, + setChartTitle, + errorMessage, + isExecuting, + rowData, + schemaData, + columnData, + chartTitle, + chartType, + xAxis, + yAxis, + + + }; +} \ No newline at end of file diff --git a/src/components/database/ContentViewerPanel.tsx b/src/features/database/components/ContentViewerPanel.tsx similarity index 98% rename from src/components/database/ContentViewerPanel.tsx rename to src/features/database/components/ContentViewerPanel.tsx index 266deb2..dc96811 100644 --- a/src/components/database/ContentViewerPanel.tsx +++ b/src/features/database/components/ContentViewerPanel.tsx @@ -1,8 +1,8 @@ import { RefreshCw, Plus, TrendingUp, Search, X, Loader2, Table } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { DataTable } from '@/components/common/DataTable'; -import { TableRow } from '@/types/database'; +import { DataTable } from '@/components/shared/DataTable'; +import { TableRow } from '@/features/database/types'; interface ContentViewerPanelProps { selectedTable: string | null; diff --git a/src/features/database/components/DataViewPanel.tsx b/src/features/database/components/DataViewPanel.tsx new file mode 100644 index 0000000..5880be3 --- /dev/null +++ b/src/features/database/components/DataViewPanel.tsx @@ -0,0 +1,238 @@ +// features/database/components/DataViewPanel.tsx + +import { RefreshCw, Download, FileText, ChevronDown, PanelLeftClose, PanelLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import TablesExplorerPanel from "./TablesExplorerPanel"; +import ContentViewerPanel from "./ContentViewerPanel"; +import { SelectedTable } from "../types"; + +interface DataViewPanelProps { + // Database info + dbId: string; + databaseName: string | undefined; + tables: any[]; + selectedTable: SelectedTable | null; + schemas: string[]; + selectedSchema: string; + setSelectedSchema: (schema: string) => void; + + // Data + tableData: any[]; + totalRows: number; + currentPage: number; + pageSize: number; + isLoadingData: boolean; + loadingTables: boolean; + + // Sidebar + sidebarOpen: boolean; + onToggleSidebar: () => void; + + // Actions + onRefresh: () => void; + onMigrationsOpen: () => void; + onExport: (format: "csv" | "json") => void; + isExporting: boolean; + onChart: () => void; + onInsert: () => void; + onTableSelect: (tableName: string, schemaName: string) => Promise; + onPageChange: (page: number) => void; + onPageSizeChange: (size: number) => void; + + // Search + searchTerm: string; + searchResults: Record[] | null; + searchResultCount: number | undefined; + isSearching: boolean; + searchPage: number; + isSearchActive: boolean; + onSearchChange: (term: string) => void; + onSearch: () => void; + onSearchPageChange: (page: number) => void; + onSearchRefresh: () => void; + + // Row operations + onEditRow: (row: Record) => void; + onDeleteRow: (row: Record) => void; +} + +export const DataViewPanel = ({ + dbId, + databaseName, + tables, + selectedTable, + schemas, + selectedSchema, + setSelectedSchema, + tableData, + totalRows, + currentPage, + pageSize, + isLoadingData, + loadingTables, + sidebarOpen, + onToggleSidebar, + onRefresh, + onMigrationsOpen, + onExport, + isExporting, + onChart, + onInsert, + onTableSelect, + onPageChange, + onPageSizeChange, + searchTerm, + searchResults, + searchResultCount, + isSearching, + searchPage, + isSearchActive, + onSearchChange, + onSearch, + onSearchPageChange, + onSearchRefresh, + onEditRow, + onDeleteRow, +}: DataViewPanelProps) => { + return ( + <> + {/* Header */} + + + + + {sidebarOpen ? ( + + ) : ( + + )} + + + + {databaseName || "Database"} + {schemas.length > 0 && ( + + + + {selectedSchema} + + + + + {schemas.map((s) => ( + setSelectedSchema(s)}> + {s} + + ))} + + + )} + + + {tables.length} tables in {selectedSchema} + + + + + + + + Migrations + + + + + {isExporting ? ( + <> + + Exporting... + > + ) : ( + <> + + Export + + > + )} + + + + onExport("csv")}> + Export as CSV + + onExport("json")}> + Export as JSON + + + + + {loadingTables ? : } + + + + + + {/* Content */} + + + + + + + + + + > + ); +}; \ No newline at end of file diff --git a/src/features/database/components/DatabaseErrorView.tsx b/src/features/database/components/DatabaseErrorView.tsx new file mode 100644 index 0000000..9c93c1c --- /dev/null +++ b/src/features/database/components/DatabaseErrorView.tsx @@ -0,0 +1,43 @@ +import { RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface DatabaseErrorViewProps { + error: string; + isRetrying: boolean; + onRetry: () => void; +} + +export const DatabaseErrorView = ({ error, isRetrying, onRetry }: DatabaseErrorViewProps) => ( + + + + Connection Error + + + + Failed to connect to the database: + + + {error} + + + + {isRetrying ? ( + <> + + Retrying... + > + ) : ( + <> + + Retry + > + )} + + + + + +); \ No newline at end of file diff --git a/src/components/database/EditRowDialog.tsx b/src/features/database/components/EditRowDialog.tsx similarity index 56% rename from src/components/database/EditRowDialog.tsx rename to src/features/database/components/EditRowDialog.tsx index 47f07f9..6fc8856 100644 --- a/src/components/database/EditRowDialog.tsx +++ b/src/features/database/components/EditRowDialog.tsx @@ -1,5 +1,3 @@ -import { useState, useEffect } from "react"; -import { toast } from "sonner"; import { Dialog, DialogContent, @@ -14,7 +12,7 @@ import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; -import { bridgeApi } from "@/services/bridgeApi"; +import { useEditDialog } from "../hooks/useEditDialog"; interface EditRowDialogProps { open: boolean; @@ -37,114 +35,29 @@ export default function EditRowDialog({ rowData, onSuccess, }: EditRowDialogProps) { - const [formData, setFormData] = useState>({}); - const [nullFields, setNullFields] = useState>(new Set()); - const [submitting, setSubmitting] = useState(false); - // Initialize form data when dialog opens - useEffect(() => { - if (open && rowData) { - const initial: Record = {}; - const nulls = new Set(); - - Object.entries(rowData).forEach(([key, value]) => { - if (value === null) { - nulls.add(key); - initial[key] = ""; - } else if (typeof value === "object") { - initial[key] = JSON.stringify(value); - } else { - initial[key] = String(value); - } - }); - - setFormData(initial); - setNullFields(nulls); - } - }, [open, rowData]); - - const handleInputChange = (columnName: string, value: string) => { - setFormData(prev => ({ ...prev, [columnName]: value })); - if (value) { - setNullFields(prev => { - const next = new Set(prev); - next.delete(columnName); - return next; - }); - } - }; - - const toggleNull = (columnName: string, checked: boolean) => { - setNullFields(prev => { - const next = new Set(prev); - if (checked) { - next.add(columnName); - setFormData(p => ({ ...p, [columnName]: "" })); - } else { - next.delete(columnName); - } - return next; - }); - }; - - const handleSubmit = async () => { - setSubmitting(true); - try { - const updatedData: Record = {}; - const primaryKeyValue = rowData[primaryKeyColumn]; - - Object.keys(formData).forEach(key => { - // Skip primary key in update data - if (key === primaryKeyColumn) return; - - if (nullFields.has(key)) { - updatedData[key] = null; - } else { - const value = formData[key]; - const originalValue = rowData[key]; - - // Try to preserve original type - if (typeof originalValue === "number") { - const num = parseFloat(value); - updatedData[key] = isNaN(num) ? value : num; - } else if (typeof originalValue === "boolean") { - updatedData[key] = value.toLowerCase() === "true" || value === "1"; - } else if (typeof originalValue === "object" && originalValue !== null) { - try { - updatedData[key] = JSON.parse(value); - } catch { - updatedData[key] = value; - } - } else { - updatedData[key] = value; - } - } - }); - - await bridgeApi.updateRow({ - dbId, - schemaName, - tableName, - primaryKeyColumn, - primaryKeyValue, - rowData: updatedData, - }); - - toast.success("Row updated successfully"); - onOpenChange(false); - onSuccess?.(); - } catch (error: any) { - toast.error(error.message || "Failed to update row"); - } finally { - setSubmitting(false); - } - }; - - const columns = Object.keys(rowData || {}); + const { + formData, + nullFields, + submitting, + handleInputChange, + toggleNull, + handleSubmit, + columns, + } = useEditDialog({ + open, + onOpenChange, + dbId, + schemaName, + tableName, + primaryKeyColumn, + rowData, + onSuccess + }) return ( - + Edit Row @@ -155,7 +68,7 @@ export default function EditRowDialog({ - + {columns.map((col) => { const isPK = col === primaryKeyColumn; diff --git a/src/components/database/ExpandableBottomPanel.tsx b/src/features/database/components/ExpandableBottomPanel.tsx similarity index 92% rename from src/components/database/ExpandableBottomPanel.tsx rename to src/features/database/components/ExpandableBottomPanel.tsx index 4e6c2fd..8eb0e4e 100644 --- a/src/components/database/ExpandableBottomPanel.tsx +++ b/src/features/database/components/ExpandableBottomPanel.tsx @@ -22,12 +22,12 @@ export default function ExpandableBottomPanel({ className={` border-t border-border/20 bg-background transition-all duration-300 ease-in-out - ${isExpanded ? '' : 'h-[60px]'} + ${isExpanded ? '' : 'h-15'} `} style={isExpanded ? { height: defaultHeight } : undefined} > {/* Header Bar */} - + {title} ([]); - const [foreignKeys, setForeignKeys] = useState([]); - const [formData, setFormData] = useState>({}); - const [nullFields, setNullFields] = useState>(new Set()); - const [loading, setLoading] = useState(false); - const [submitting, setSubmitting] = useState(false); - // FK lookup values cache - const [fkOptions, setFkOptions] = useState>({}); - const [loadingFKs, setLoadingFKs] = useState>(new Set()); - - // Reset state when dialog closes - useEffect(() => { - if (!open) { - setColumns([]); - setForeignKeys([]); - setFormData({}); - setNullFields(new Set()); - setFkOptions({}); - setLoadingFKs(new Set()); - } - }, [open]); - - // Fetch schema when dialog opens - useEffect(() => { - if (open && dbId && schemaName && tableName) { - fetchTableInfo(); - } - }, [open, dbId, schemaName, tableName]); - - const fetchTableInfo = async () => { - setLoading(true); - try { - const schemaData = await bridgeApi.getSchema(dbId); - if (!schemaData) { - throw new Error("Failed to fetch schema"); - } - - // Find the target table in schema - const schema = schemaData.schemas.find(s => s.name === schemaName); - const table = schema?.tables.find(t => t.name === tableName); - - if (!table) { - throw new Error(`Table ${schemaName}.${tableName} not found`); - } - - setColumns(table.columns || []); - setForeignKeys(table.foreignKeys || []); - - // Initialize form data with empty strings - const initialData: Record = {}; - table.columns?.forEach((col) => { - initialData[col.name] = ""; - }); - setFormData(initialData); - - // Load FK options for each FK column - const fkMap: Record = {}; - table.foreignKeys?.forEach(fk => { - fkMap[fk.source_column] = fk; - }); - - // Fetch FK reference values - for (const fk of table.foreignKeys || []) { - loadFKOptions(fk); - } - } catch (error) { - toast.error("Failed to fetch table information"); - console.error(error); - } finally { - setLoading(false); - } - }; - - const loadFKOptions = async (fk: ForeignKeyInfo) => { - const key = fk.source_column; - setLoadingFKs(prev => new Set(prev).add(key)); - - try { - // Fetch first 100 rows from referenced table - const result = await bridgeApi.fetchTableData( - dbId, - fk.target_schema, - fk.target_table, - 100, - 1 - ); - - const options: FKOption[] = result.rows.map(row => { - const value = String(row[fk.target_column] ?? ""); - // Try to show a more descriptive label if there's a name column - const nameCol = Object.keys(row).find(k => - k.toLowerCase().includes('name') || - k.toLowerCase().includes('title') - ); - const label = nameCol ? `${value} - ${row[nameCol]}` : value; - return { value, label }; - }); - - setFkOptions(prev => ({ ...prev, [key]: options })); - } catch (error) { - console.error(`Failed to load FK options for ${fk.target_table}:`, error); - setFkOptions(prev => ({ ...prev, [key]: [] })); - } finally { - setLoadingFKs(prev => { - const next = new Set(prev); - next.delete(key); - return next; - }); - } - }; - - const getFKForColumn = (columnName: string): ForeignKeyInfo | undefined => { - return foreignKeys.find(fk => fk.source_column === columnName); - }; - - const handleInputChange = (columnName: string, value: string) => { - setFormData(prev => ({ ...prev, [columnName]: value })); - // If user types something, remove from null fields - if (value) { - setNullFields(prev => { - const next = new Set(prev); - next.delete(columnName); - return next; - }); - } - }; - - const toggleNull = (columnName: string, checked: boolean) => { - setNullFields(prev => { - const next = new Set(prev); - if (checked) { - next.add(columnName); - setFormData(p => ({ ...p, [columnName]: "" })); - } else { - next.delete(columnName); - } - return next; - }); - }; - - const handleSubmit = async () => { - setSubmitting(true); - try { - const rowData: Record = {}; - - for (const col of columns) { - const value = formData[col.name]; - const isNull = nullFields.has(col.name); - - if (isNull) { - rowData[col.name] = null; - } else if (value === "") { - // Skip empty fields - let DB use defaults or error - if (col.defaultValue) { - continue; - } - if (!col.nullable) { - continue; - } - // Required field with no value - let DB handle - continue; - } else { - rowData[col.name] = parseValue(value, col.type); - } - } - - await bridgeApi.insertRow({ - dbId, - schemaName, - tableName, - rowData, - }); - - toast.success("Row inserted successfully"); - onOpenChange(false); - onSuccess?.(); - } catch (error: any) { - toast.error(error.message || "Failed to insert row"); - } finally { - setSubmitting(false); - } - }; - - const parseValue = (value: string, type: string): any => { - const lowerType = type.toLowerCase(); - - if (lowerType.includes("int") || lowerType.includes("serial")) { - const num = parseInt(value, 10); - return isNaN(num) ? value : num; - } - if (lowerType.includes("float") || lowerType.includes("double") || - lowerType.includes("decimal") || lowerType.includes("numeric") || - lowerType.includes("real")) { - const num = parseFloat(value); - return isNaN(num) ? value : num; - } - if (lowerType.includes("bool")) { - return value.toLowerCase() === "true" || value === "1"; - } - if (lowerType.includes("json")) { - try { - return JSON.parse(value); - } catch { - return value; - } - } - - return value; - }; - - const getPlaceholder = (col: ColumnDetails): string => { - if (col.defaultValue) { - return `Default: ${col.defaultValue}`; - } - return col.type; - }; + const { + columns, + foreignKeys, + formData, + nullFields, + loading, + submitting, + getFKForColumn, + handleInputChange, + toggleNull, + handleSubmit, + getPlaceholder, + loadingFKs, + fkOptions + } = useInsertDataDialog({ + open, + onOpenChange, + dbId, + schemaName, + tableName, + onSuccess + }) const renderInput = (col: ColumnDetails) => { const fk = getFKForColumn(col.name); @@ -270,6 +74,7 @@ export default function InsertDataDialog({ const isLoadingFK = loadingFKs.has(col.name); const options = fkOptions[col.name] || []; + // FK column - show dropdown if (fk && options.length > 0) { return ( @@ -316,7 +121,7 @@ export default function InsertDataDialog({ return ( - + Insert Row @@ -329,7 +134,7 @@ export default function InsertDataDialog({ ) : ( - + {columns.map((col) => { const fk = getFKForColumn(col.name); diff --git a/src/components/database/MigrationsPanel.tsx b/src/features/database/components/MigrationsPanel.tsx similarity index 74% rename from src/components/database/MigrationsPanel.tsx rename to src/features/database/components/MigrationsPanel.tsx index d994b7a..08bdd13 100644 --- a/src/components/database/MigrationsPanel.tsx +++ b/src/features/database/components/MigrationsPanel.tsx @@ -1,14 +1,11 @@ -import { useState } from "react"; import { CheckCircle2, Clock, AlertCircle, Database, Play, Undo2, Trash2, Eye, RefreshCw } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { LocalMigration, AppliedMigration, MigrationsData } from "@/types/database"; +import { MigrationsData } from "@/features/database/types"; +import { useMigrationsPanel } from "../hooks/useMigrationsPanel"; import { cn } from "@/lib/utils"; -import { useQueryClient } from "@tanstack/react-query"; -import { bridgeApi } from "@/services/bridgeApi"; -import { toast } from "sonner"; interface MigrationsPanelProps { migrations: MigrationsData; @@ -17,107 +14,22 @@ interface MigrationsPanelProps { } export default function MigrationsPanel({ migrations, baselined, dbId }: MigrationsPanelProps) { - const { local, applied } = migrations; - const queryClient = useQueryClient(); - const [selectedMigration, setSelectedMigration] = useState<{ version: string; name: string } | null>(null); - const [showSQLDialog, setShowSQLDialog] = useState(false); - const [sqlContent, setSqlContent] = useState<{ up: string; down: string } | null>(null); - const [isRefreshing, setIsRefreshing] = useState(false); - const handleRefresh = async () => { - setIsRefreshing(true); - try { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["migrations", dbId] }), - queryClient.invalidateQueries({ queryKey: ["tables", dbId] }), - queryClient.invalidateQueries({ queryKey: ["schema", dbId] }), - queryClient.invalidateQueries({ queryKey: ["schemaNames", dbId] }), - ]); - toast.success("Refreshed successfully"); - } finally { - setIsRefreshing(false); - } - }; - - // Merge and sort migrations - const appliedVersions = new Set(applied.map((m) => m.version)); - - const allMigrations = [ - ...applied.map((m) => ({ - version: m.version, - name: m.name, - status: "applied" as const, - appliedAt: m.applied_at, - checksum: m.checksum, - })), - ...local - .filter((m) => !appliedVersions.has(m.version)) - .map((m) => ({ - version: m.version, - name: m.name, - status: "pending" as const, - })), - ].sort((a, b) => a.version.localeCompare(b.version)); - - const handleApply = async (version: string, name: string) => { - try { - await bridgeApi.applyMigration(dbId, version); - toast.success("Migration applied successfully", { - description: `Applied migration: ${name}`, - }); - // Invalidate migrations query to refresh - queryClient.invalidateQueries({ queryKey: ["migrations", dbId] }); - queryClient.invalidateQueries({ queryKey: ["tables", dbId] }); - queryClient.invalidateQueries({ queryKey: ["schema", dbId] }); - } catch (error: any) { - toast.error("Failed to apply migration", { - description: error.message, - }); - } - }; - - const handleRollback = async (version: string, name: string) => { - try { - await bridgeApi.rollbackMigration(dbId, version); - toast.success("Migration rolled back successfully", { - description: `Rolled back migration: ${name}`, - }); - queryClient.invalidateQueries({ queryKey: ["migrations", dbId] }); - queryClient.invalidateQueries({ queryKey: ["tables", dbId] }); - queryClient.invalidateQueries({ queryKey: ["schema", dbId] }); - } catch (error: any) { - toast.error("Failed to rollback migration", { - description: error.message, - }); - } - }; - - const handleDelete = async (version: string, name: string) => { - try { - await bridgeApi.deleteMigration(dbId, version); - toast.success("Migration deleted successfully", { - description: `Deleted migration: ${name}`, - }); - queryClient.invalidateQueries({ queryKey: ["migrations", dbId] }); - } catch (error: any) { - toast.error("Failed to delete migration", { - description: error.message, - }); - } - }; - - const handleViewSQL = async (version: string, name: string) => { - try { - const sql = await bridgeApi.getMigrationSQL(dbId, version); - setSqlContent(sql); - setSelectedMigration({ version, name }); - setShowSQLDialog(true); - } catch (error: any) { - toast.error("Failed to load migration SQL", { - description: error.message, - }); - } - }; + const { + allMigrations, + applied, + local, + selectedMigration, + showSQLDialog, + sqlContent, + isRefreshing, + handleRefresh, + handleApply, + handleRollback, + handleDelete, + handleViewSQL, + setShowSQLDialog, + } = useMigrationsPanel({ migrations, baselined, dbId }) return ( <> diff --git a/src/components/database/TablesExplorerPanel.tsx b/src/features/database/components/TablesExplorerPanel.tsx similarity index 79% rename from src/components/database/TablesExplorerPanel.tsx rename to src/features/database/components/TablesExplorerPanel.tsx index 4b81067..8041f06 100644 --- a/src/components/database/TablesExplorerPanel.tsx +++ b/src/features/database/components/TablesExplorerPanel.tsx @@ -4,8 +4,10 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Badge } from '@/components/ui/badge'; -import { SelectedTable, TableInfo } from '@/types/database'; -import CreateTableDialog from '../schema-explorer/CreateTableDialog'; +import { SelectedTable, TableInfo } from "@/features/database/types"; +import { CreateTableDialog } from '@/features/schema-explorer/components'; +import { useTableExplorerPanel } from '../hooks/useTableExplorerPanel'; + interface TablesExplorerPanelProps { dbId: string; @@ -24,37 +26,26 @@ export default function TablesExplorerPanel({ onSelectTable, loading = false, }: TablesExplorerPanelProps) { - const [searchQuery, setSearchQuery] = useState(''); - const [createTableOpen, setCreateTableOpen] = useState(false); - - const [favorites, setFavorites] = useState>( - new Set(JSON.parse(localStorage.getItem('favoriteTables') || '[]')) - ); - const [filter, setFilter] = useState<'all' | 'system' | 'favorites'>('all'); - - const toggleFavorite = (tableName: string) => { - const newFavorites = new Set(favorites); - if (newFavorites.has(tableName)) { - newFavorites.delete(tableName); - } else { - newFavorites.add(tableName); - } - setFavorites(newFavorites); - localStorage.setItem('favoriteTables', JSON.stringify(Array.from(newFavorites))); - }; - - const filteredTables = tables.filter((table) => { - const matchesSearch = table.name.toLowerCase().includes(searchQuery.toLowerCase()); - const isSystemTable = table.name.startsWith('pg_') || table.name.startsWith('information_'); - - if (filter === 'system') return matchesSearch && isSystemTable; - if (filter === 'favorites') return matchesSearch && favorites.has(table.name); - return matchesSearch && !isSystemTable; // 'all' shows non-system tables - }); - const isSelected = (table: TableInfo) => { - return selectedTable?.name === table.name && selectedTable?.schema === table.schema; - }; + const { + searchQuery, + setSearchQuery, + createTableOpen, + setCreateTableOpen, + favorites, + filter, + setFilter, + toggleFavorite, + filteredTables, + isSelected + } = useTableExplorerPanel({ + dbId, + tables, + selectedTable, + selectedSchema, + loading, + onSelectTable, + }) return ( diff --git a/src/components/database/index.ts b/src/features/database/components/index.ts similarity index 100% rename from src/components/database/index.ts rename to src/features/database/components/index.ts diff --git a/src/hooks/useCachedData.ts b/src/features/database/hooks/useCachedData.ts similarity index 100% rename from src/hooks/useCachedData.ts rename to src/features/database/hooks/useCachedData.ts diff --git a/src/features/database/hooks/useDatabaseDetailPage.ts b/src/features/database/hooks/useDatabaseDetailPage.ts new file mode 100644 index 0000000..5f3adf9 --- /dev/null +++ b/src/features/database/hooks/useDatabaseDetailPage.ts @@ -0,0 +1,27 @@ +// features/database/hooks/useDatabaseDetailPage.ts + +import { useState } from "react"; +import { PanelType } from "@/components/layout/VerticalIconBar"; + +export const useDatabaseDetailPage = () => { + const [activePanel, setActivePanel] = useState("data"); + const [migrationsOpen, setMigrationsOpen] = useState(false); + const [chartOpen, setChartOpen] = useState(false); + const [insertDialogOpen, setInsertDialogOpen] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(true); + + const toggleSidebar = () => setSidebarOpen((prev) => !prev); + + return { + activePanel, + setActivePanel, + migrationsOpen, + setMigrationsOpen, + chartOpen, + setChartOpen, + insertDialogOpen, + setInsertDialogOpen, + sidebarOpen, + toggleSidebar, + }; +}; \ No newline at end of file diff --git a/src/hooks/useDatabaseDetails.ts b/src/features/database/hooks/useDatabaseDetails.ts similarity index 96% rename from src/hooks/useDatabaseDetails.ts rename to src/features/database/hooks/useDatabaseDetails.ts index bf4ac88..dad605c 100644 --- a/src/hooks/useDatabaseDetails.ts +++ b/src/features/database/hooks/useDatabaseDetails.ts @@ -1,8 +1,9 @@ import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { bridgeApi } from "@/services/bridgeApi"; -import { useDatabase, useTables, useTableData, usePrefetch, useInvalidateCache, useSchemaNames } from "@/hooks/useDbQueries"; -import { QueryProgress, SelectedTable, TableInfo, TableRow } from "@/types/database"; +import { useDatabase, useTables, useTableData, usePrefetch, useInvalidateCache, useSchemaNames } from "@/features/project/hooks/useDbQueries"; +import { QueryProgress, SelectedTable, TableInfo, TableRow } from "@/features/database/types"; +import { sessionService } from "@/services/bridge/session"; +import { queryService } from "@/services/bridge/query"; interface UseDatabaseDetailsOptions { dbId: string | undefined; @@ -171,7 +172,7 @@ export function useDatabaseDetails({ if (!querySessionId) return; try { - const cancelled = await bridgeApi.cancelSession(querySessionId); + const cancelled = await sessionService.cancelSession(querySessionId); if (cancelled) { toast.info("Cancelling query...", { description: "Stopping query execution" }); } @@ -200,12 +201,12 @@ export function useDatabaseDetails({ setHasExecutedQuery(true); setIsExecuting(true); - const sessionId = await bridgeApi.createSession(); + const sessionId = await sessionService.createSession(); setQuerySessionId(sessionId); toast.info("Executing query...", { description: "Query started, receiving results..." }); - await bridgeApi.runQuery({ + await queryService.runQuery({ sessionId, dbId, sql: query, diff --git a/src/features/database/hooks/useDatabaseStats.ts b/src/features/database/hooks/useDatabaseStats.ts new file mode 100644 index 0000000..1ad2314 --- /dev/null +++ b/src/features/database/hooks/useDatabaseStats.ts @@ -0,0 +1,58 @@ +// features/database/hooks/useDatabaseStats.ts + +import { useQuery } from "@tanstack/react-query"; +import { useCachedConnectionStatus, useCachedTotalStats, useCachedDbStats } from "./useCachedData"; +import { bytesToMBString } from "@/lib/bytesToMB"; +import { databaseService } from "@/services/bridge/database"; + +export const useDatabaseStats = (bridgeReady: boolean, hasDatabases: boolean) => { + const { cachedStats, updateCache: updateStatsCache } = useCachedTotalStats(); + const { cachedStatus, updateCache: updateStatusCache } = useCachedConnectionStatus(); + + const { data: stats, isLoading: statsLoading } = useQuery({ + queryKey: ["totalStats"], + queryFn: async () => { + const result = await databaseService.getTotalDatabaseStats(); + if (result) updateStatsCache(result); + return result; + }, + enabled: !!bridgeReady && hasDatabases, + staleTime: 30 * 1000, + }); + + const { data: statusData, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ + queryKey: ["connectionStatus"], + queryFn: async () => { + const res = await databaseService.testAllConnections(); + const statusMap = new Map(); + res.forEach((r) => statusMap.set(r.id, r.result.status)); + updateStatusCache(statusMap); + return statusMap; + }, + enabled: !!bridgeReady, + staleTime: 60 * 1000, + }); + + // Fresh data with cache fallback + const status = statusData || cachedStatus; + const effectiveStats = stats || cachedStats; + + // Derived values — computed here, not in the page + const totalSize = effectiveStats?.sizeBytes ? bytesToMBString(effectiveStats.sizeBytes) : "—"; + const totalTables = effectiveStats?.tables ?? "—"; + const connectedCount = [...status.values()].filter((s) => s === "connected").length; + + // Show loading only when no cache exists + const showStatsLoading = statsLoading && !cachedStats; + const showStatusLoading = statusLoading && cachedStatus.size === 0; + + return { + status, + totalSize, + totalTables, + connectedCount, + showStatsLoading, + showStatusLoading, + refetchStatus, + }; +}; \ No newline at end of file diff --git a/src/hooks/useDiscoveredDatabases.ts b/src/features/database/hooks/useDiscoveredDatabases.ts similarity index 85% rename from src/hooks/useDiscoveredDatabases.ts rename to src/features/database/hooks/useDiscoveredDatabases.ts index 98c8d36..2137844 100644 --- a/src/hooks/useDiscoveredDatabases.ts +++ b/src/features/database/hooks/useDiscoveredDatabases.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from "react"; -import { bridgeApi } from "@/services/bridgeApi"; -import { DiscoveredDatabase } from "@/types/database"; +import { DiscoveredDatabase } from "@/features/database/types"; +import { databaseService } from "@/services/bridge/database"; interface UseDiscoveredDatabasesReturn { databases: DiscoveredDatabase[]; @@ -25,7 +25,7 @@ export function useDiscoveredDatabases(): UseDiscoveredDatabasesReturn { setError(null); try { - const discovered = await bridgeApi.discoverDatabases(); + const discovered = await databaseService.discoverDatabases(); setDatabases(discovered); setLastScanned(new Date()); } catch (err: any) { diff --git a/src/features/database/hooks/useEditDialog.ts b/src/features/database/hooks/useEditDialog.ts new file mode 100644 index 0000000..5766af6 --- /dev/null +++ b/src/features/database/hooks/useEditDialog.ts @@ -0,0 +1,140 @@ +import { useState, useEffect } from "react"; +import { databaseService } from "@/services/bridge/database"; +import { toast } from "sonner"; + +interface EditRowDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + dbId: string; + schemaName: string; + tableName: string; + primaryKeyColumn: string; + rowData: Record; + onSuccess?: () => void; +} + +export function useEditDialog({ + open, + onOpenChange, + dbId, + schemaName, + tableName, + primaryKeyColumn, + rowData, + onSuccess, +}: EditRowDialogProps) { + const [formData, setFormData] = useState>({}); + const [nullFields, setNullFields] = useState>(new Set()); + const [submitting, setSubmitting] = useState(false); + + // Initialize form data when dialog opens + useEffect(() => { + if (open && rowData) { + const initial: Record = {}; + const nulls = new Set(); + + Object.entries(rowData).forEach(([key, value]) => { + if (value === null) { + nulls.add(key); + initial[key] = ""; + } else if (typeof value === "object") { + initial[key] = JSON.stringify(value); + } else { + initial[key] = String(value); + } + }); + + setFormData(initial); + setNullFields(nulls); + } + }, [open, rowData]); + + const handleInputChange = (columnName: string, value: string) => { + setFormData(prev => ({ ...prev, [columnName]: value })); + if (value) { + setNullFields(prev => { + const next = new Set(prev); + next.delete(columnName); + return next; + }); + } + }; + + const toggleNull = (columnName: string, checked: boolean) => { + setNullFields(prev => { + const next = new Set(prev); + if (checked) { + next.add(columnName); + setFormData(p => ({ ...p, [columnName]: "" })); + } else { + next.delete(columnName); + } + return next; + }); + }; + + const handleSubmit = async () => { + setSubmitting(true); + try { + const updatedData: Record = {}; + const primaryKeyValue = rowData[primaryKeyColumn]; + + Object.keys(formData).forEach(key => { + // Skip primary key in update data + if (key === primaryKeyColumn) return; + + if (nullFields.has(key)) { + updatedData[key] = null; + } else { + const value = formData[key]; + const originalValue = rowData[key]; + + // Try to preserve original type + if (typeof originalValue === "number") { + const num = parseFloat(value); + updatedData[key] = isNaN(num) ? value : num; + } else if (typeof originalValue === "boolean") { + updatedData[key] = value.toLowerCase() === "true" || value === "1"; + } else if (typeof originalValue === "object" && originalValue !== null) { + try { + updatedData[key] = JSON.parse(value); + } catch { + updatedData[key] = value; + } + } else { + updatedData[key] = value; + } + } + }); + + await databaseService.updateRow({ + dbId, + schemaName, + tableName, + primaryKeyColumn, + primaryKeyValue, + rowData: updatedData, + }); + + toast.success("Row updated successfully"); + onOpenChange(false); + onSuccess?.(); + } catch (error: any) { + toast.error(error.message || "Failed to update row"); + } finally { + setSubmitting(false); + } + }; + + const columns = Object.keys(rowData || {}); + + return { + formData, + nullFields, + submitting, + handleInputChange, + toggleNull, + handleSubmit, + columns, + } +} \ No newline at end of file diff --git a/src/hooks/useExport.ts b/src/features/database/hooks/useExport.ts similarity index 95% rename from src/hooks/useExport.ts rename to src/features/database/hooks/useExport.ts index 2c0b0f6..5a9d71c 100644 --- a/src/hooks/useExport.ts +++ b/src/features/database/hooks/useExport.ts @@ -5,7 +5,6 @@ import { useState, useCallback } from "react"; import { toast } from "sonner"; -import { bridgeApi } from "@/services/bridgeApi"; import { convertData, downloadFile, @@ -13,7 +12,9 @@ import { getFileExtension, ExportFormat, } from "@/lib/dataExport"; -import { TableInfo, TableRow } from "@/types/database"; +import { TableInfo, TableRow } from "@/features/database/types"; +import { databaseService } from "@/services/bridge/database"; +import { queryService } from "@/services/bridge/query"; interface ExportProgress { currentTable: string; @@ -49,7 +50,7 @@ export function useExport({ dbId, databaseName }: UseExportOptions) { let hasMore = true; while (hasMore) { - const result = await bridgeApi.fetchTableData( + const result = await queryService.fetchTableData( dbId, schemaName, tableName, @@ -93,7 +94,7 @@ export function useExport({ dbId, databaseName }: UseExportOptions) { status: "fetching", currentTable: "Loading table list...", })); - const tables: TableInfo[] = await bridgeApi.listTables(dbId); + const tables: TableInfo[] = await databaseService.listTables(dbId); if (!tables || tables.length === 0) { toast.error("No tables found to export", { id: toastId }); diff --git a/src/features/database/hooks/useInsertDataDialog.ts b/src/features/database/hooks/useInsertDataDialog.ts new file mode 100644 index 0000000..bf78b31 --- /dev/null +++ b/src/features/database/hooks/useInsertDataDialog.ts @@ -0,0 +1,260 @@ +import { useEffect, useState } from "react"; +import { ColumnDetails, ForeignKeyInfo } from "../types"; +import { databaseService } from "@/services/bridge/database"; +import { queryService } from "@/services/bridge/query"; +import { toast } from "sonner"; + +interface InsertDataDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + dbId: string; + schemaName: string; + tableName: string; + onSuccess?: () => void; +} + +interface FKOption { + value: string; + label: string; +} + +export function useInsertDataDialog({ open, + onOpenChange, + dbId, + schemaName, + tableName, + onSuccess, }: InsertDataDialogProps) { + const [columns, setColumns] = useState([]); + const [foreignKeys, setForeignKeys] = useState([]); + const [formData, setFormData] = useState>({}); + const [nullFields, setNullFields] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + // FK lookup values cache + const [fkOptions, setFkOptions] = useState>({}); + const [loadingFKs, setLoadingFKs] = useState>(new Set()); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setColumns([]); + setForeignKeys([]); + setFormData({}); + setNullFields(new Set()); + setFkOptions({}); + setLoadingFKs(new Set()); + } + }, [open]); + + // Fetch schema when dialog opens + useEffect(() => { + if (open && dbId && schemaName && tableName) { + fetchTableInfo(); + } + }, [open, dbId, schemaName, tableName]); + + const fetchTableInfo = async () => { + setLoading(true); + try { + const schemaData = await databaseService.getSchema(dbId); + if (!schemaData) { + throw new Error("Failed to fetch schema"); + } + + // Find the target table in schema + const schema = schemaData.schemas.find(s => s.name === schemaName); + const table = schema?.tables.find(t => t.name === tableName); + + if (!table) { + throw new Error(`Table ${schemaName}.${tableName} not found`); + } + + setColumns(table.columns || []); + setForeignKeys(table.foreignKeys || []); + + // Initialize form data with empty strings + const initialData: Record = {}; + table.columns?.forEach((col) => { + initialData[col.name] = ""; + }); + setFormData(initialData); + + // Load FK options for each FK column + const fkMap: Record = {}; + table.foreignKeys?.forEach(fk => { + fkMap[fk.source_column] = fk; + }); + + // Fetch FK reference values + for (const fk of table.foreignKeys || []) { + loadFKOptions(fk); + } + } catch (error) { + toast.error("Failed to fetch table information"); + console.error(error); + } finally { + setLoading(false); + } + }; + + const loadFKOptions = async (fk: ForeignKeyInfo) => { + const key = fk.source_column; + setLoadingFKs(prev => new Set(prev).add(key)); + + try { + // Fetch first 100 rows from referenced table + const result = await queryService.fetchTableData( + dbId, + fk.target_schema, + fk.target_table, + 100, + 1 + ); + + const options: FKOption[] = result.rows.map(row => { + const value = String(row[fk.target_column] ?? ""); + // Try to show a more descriptive label if there's a name column + const nameCol = Object.keys(row).find(k => + k.toLowerCase().includes('name') || + k.toLowerCase().includes('title') + ); + const label = nameCol ? `${value} - ${row[nameCol]}` : value; + return { value, label }; + }); + + setFkOptions(prev => ({ ...prev, [key]: options })); + } catch (error) { + console.error(`Failed to load FK options for ${fk.target_table}:`, error); + setFkOptions(prev => ({ ...prev, [key]: [] })); + } finally { + setLoadingFKs(prev => { + const next = new Set(prev); + next.delete(key); + return next; + }); + } + }; + + const getFKForColumn = (columnName: string): ForeignKeyInfo | undefined => { + return foreignKeys.find(fk => fk.source_column === columnName); + }; + + const handleInputChange = (columnName: string, value: string) => { + setFormData(prev => ({ ...prev, [columnName]: value })); + // If user types something, remove from null fields + if (value) { + setNullFields(prev => { + const next = new Set(prev); + next.delete(columnName); + return next; + }); + } + }; + + const toggleNull = (columnName: string, checked: boolean) => { + setNullFields(prev => { + const next = new Set(prev); + if (checked) { + next.add(columnName); + setFormData(p => ({ ...p, [columnName]: "" })); + } else { + next.delete(columnName); + } + return next; + }); + }; + + const handleSubmit = async () => { + setSubmitting(true); + try { + const rowData: Record = {}; + + for (const col of columns) { + const value = formData[col.name]; + const isNull = nullFields.has(col.name); + + if (isNull) { + rowData[col.name] = null; + } else if (value === "") { + // Skip empty fields - let DB use defaults or error + if (col.defaultValue) { + continue; + } + if (!col.nullable) { + continue; + } + // Required field with no value - let DB handle + continue; + } else { + rowData[col.name] = parseValue(value, col.type); + } + } + + await databaseService.insertRow({ + dbId, + schemaName, + tableName, + rowData, + }); + + toast.success("Row inserted successfully"); + onOpenChange(false); + onSuccess?.(); + } catch (error: any) { + toast.error(error.message || "Failed to insert row"); + } finally { + setSubmitting(false); + } + }; + + const parseValue = (value: string, type: string): any => { + const lowerType = type.toLowerCase(); + + if (lowerType.includes("int") || lowerType.includes("serial")) { + const num = parseInt(value, 10); + return isNaN(num) ? value : num; + } + if (lowerType.includes("float") || lowerType.includes("double") || + lowerType.includes("decimal") || lowerType.includes("numeric") || + lowerType.includes("real")) { + const num = parseFloat(value); + return isNaN(num) ? value : num; + } + if (lowerType.includes("bool")) { + return value.toLowerCase() === "true" || value === "1"; + } + if (lowerType.includes("json")) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + + return value; + }; + + const getPlaceholder = (col: ColumnDetails): string => { + if (col.defaultValue) { + return `Default: ${col.defaultValue}`; + } + return col.type; + }; + + return { + columns, + foreignKeys, + formData, + nullFields, + loading, + submitting, + getFKForColumn, + handleInputChange, + toggleNull, + handleSubmit, + getPlaceholder, + loadingFKs, + fkOptions + } +} \ No newline at end of file diff --git a/src/features/database/hooks/useMigrationsPanel.ts b/src/features/database/hooks/useMigrationsPanel.ts new file mode 100644 index 0000000..f60bdfb --- /dev/null +++ b/src/features/database/hooks/useMigrationsPanel.ts @@ -0,0 +1,134 @@ +import { MigrationsData } from "@/features/database/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { migrationService } from "@/services/bridge/migration"; +import { useState } from "react"; + +interface MigrationsPanelProps { + migrations: MigrationsData; + baselined: boolean; + dbId: string; +} + + + +export function useMigrationsPanel({ migrations, baselined, dbId }: MigrationsPanelProps) { + const { local, applied } = migrations; + const queryClient = useQueryClient(); + const [selectedMigration, setSelectedMigration] = useState<{ version: string; name: string } | null>(null); + const [showSQLDialog, setShowSQLDialog] = useState(false); + const [sqlContent, setSqlContent] = useState<{ up: string; down: string } | null>(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["migrations", dbId] }), + queryClient.invalidateQueries({ queryKey: ["tables", dbId] }), + queryClient.invalidateQueries({ queryKey: ["schema", dbId] }), + queryClient.invalidateQueries({ queryKey: ["schemaNames", dbId] }), + ]); + toast.success("Refreshed successfully"); + } finally { + setIsRefreshing(false); + } + }; + + // Merge and sort migrations + const appliedVersions = new Set(applied.map((m) => m.version)); + + const allMigrations = [ + ...applied.map((m) => ({ + version: m.version, + name: m.name, + status: "applied" as const, + appliedAt: m.applied_at, + checksum: m.checksum, + })), + ...local + .filter((m) => !appliedVersions.has(m.version)) + .map((m) => ({ + version: m.version, + name: m.name, + status: "pending" as const, + })), + ].sort((a, b) => a.version.localeCompare(b.version)); + + const handleApply = async (version: string, name: string) => { + try { + await migrationService.applyMigration(dbId, version); + toast.success("Migration applied successfully", { + description: `Applied migration: ${name}`, + }); + // Invalidate migrations query to refresh + queryClient.invalidateQueries({ queryKey: ["migrations", dbId] }); + queryClient.invalidateQueries({ queryKey: ["tables", dbId] }); + queryClient.invalidateQueries({ queryKey: ["schema", dbId] }); + } catch (error: any) { + toast.error("Failed to apply migration", { + description: error.message, + }); + } + }; + + const handleRollback = async (version: string, name: string) => { + try { + await migrationService.rollbackMigration(dbId, version); + toast.success("Migration rolled back successfully", { + description: `Rolled back migration: ${name}`, + }); + queryClient.invalidateQueries({ queryKey: ["migrations", dbId] }); + queryClient.invalidateQueries({ queryKey: ["tables", dbId] }); + queryClient.invalidateQueries({ queryKey: ["schema", dbId] }); + } catch (error: any) { + toast.error("Failed to rollback migration", { + description: error.message, + }); + } + }; + + const handleDelete = async (version: string, name: string) => { + try { + await migrationService.deleteMigration(dbId, version); + toast.success("Migration deleted successfully", { + description: `Deleted migration: ${name}`, + }); + queryClient.invalidateQueries({ queryKey: ["migrations", dbId] }); + } catch (error: any) { + toast.error("Failed to delete migration", { + description: error.message, + }); + } + }; + + const handleViewSQL = async (version: string, name: string) => { + try { + const sql = await migrationService.getMigrationSQL(dbId, version); + setSqlContent(sql); + setSelectedMigration({ version, name }); + setShowSQLDialog(true); + } catch (error: any) { + toast.error("Failed to load migration SQL", { + description: error.message, + }); + } + }; + + return { + allMigrations, + baselined, + selectedMigration, + showSQLDialog, + sqlContent, + local, + applied, + isRefreshing, + handleRefresh, + handleApply, + handleRollback, + handleDelete, + handleViewSQL, + setShowSQLDialog, + } +} \ No newline at end of file diff --git a/src/features/database/hooks/useRowOperations.ts b/src/features/database/hooks/useRowOperations.ts new file mode 100644 index 0000000..1b86bc3 --- /dev/null +++ b/src/features/database/hooks/useRowOperations.ts @@ -0,0 +1,184 @@ +// features/database/hooks/useRowOperations.ts + +import { useState } from "react"; +import { toast } from "sonner"; +import { databaseService } from "@/services/bridge/database"; + +interface UseRowOperationsProps { + dbId: string; + selectedTable: { name: string; schema?: string } | null; + pageSize: number; + refetchTableData: () => void; +} + +export const useRowOperations = ({ + dbId, + selectedTable, + pageSize, + refetchTableData, +}: UseRowOperationsProps) => { + + // Search state + const [searchTerm, setSearchTerm] = useState(""); + const [searchResults, setSearchResults] = useState[] | null>(null); + const [searchResultCount, setSearchResultCount] = useState(undefined); + const [isSearching, setIsSearching] = useState(false); + const [searchPage, setSearchPage] = useState(1); + + // Edit state + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingRow, setEditingRow] = useState | null>(null); + const [primaryKeyColumn, setPrimaryKeyColumn] = useState(""); + + // Delete state + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingRow, setDeletingRow] = useState | null>(null); + const [deleteRowPK, setDeleteRowPK] = useState(""); + const [deleteHasPK, setDeleteHasPK] = useState(false); + + // ---- Helpers ---- + + const getSchemaName = () => selectedTable?.schema || "public"; + const getTableName = () => selectedTable?.name || ""; + + const fetchPrimaryKey = async () => { + try { + return await databaseService.getPrimaryKeys(dbId, getSchemaName(), getTableName()); + } catch { + return ""; + } + }; + + // ---- Search ---- + + const runSearch = async (term: string, page: number) => { + if (!term || !selectedTable) return; + setIsSearching(true); + try { + const result = await databaseService.searchTable({ + dbId, + schemaName: getSchemaName(), + tableName: getTableName(), + searchTerm: term, + page, + pageSize, + }); + setSearchResults(result.rows); + setSearchResultCount(result.total); + } catch (err: any) { + toast.error(err.message || "Search failed"); + setSearchResults(null); + setSearchResultCount(undefined); + } finally { + setIsSearching(false); + } + }; + + const handleSearch = async () => { + setSearchPage(1); + await runSearch(searchTerm, 1); + }; + + const handleSearchPageChange = async (page: number) => { + setSearchPage(page); + await runSearch(searchTerm, page); + }; + + const handleSearchChange = (term: string) => { + setSearchTerm(term); + if (!term) { + setSearchResults(null); + setSearchResultCount(undefined); + } + }; + + const handleSearchRefresh = () => { + if (searchResults !== null && searchTerm) { + setSearchPage(1); + runSearch(searchTerm, 1); + } + }; + + // ---- Edit ---- + + const handleEditRow = async (row: Record) => { + try { + const pk = await fetchPrimaryKey(); + setPrimaryKeyColumn(pk || Object.keys(row)[0] || ""); + setEditingRow(row); + setEditDialogOpen(true); + } catch (err: any) { + toast.error("Cannot edit: " + (err.message || "Unknown error")); + } + }; + + const handleEditSuccess = () => { + refetchTableData(); + setEditDialogOpen(false); + }; + + // ---- Delete ---- + + const handleDeleteRow = async (row: Record) => { + try { + const pk = await fetchPrimaryKey(); + setDeletingRow(row); + setDeleteRowPK(pk); + setDeleteHasPK(!!pk); + setDeleteDialogOpen(true); + } catch (err: any) { + toast.error(err.message || "Failed to prepare delete"); + } + }; + + const handleConfirmDelete = async () => { + if (!deletingRow || !selectedTable) return; + try { + await databaseService.deleteRow({ + dbId, + schemaName: getSchemaName(), + tableName: getTableName(), + primaryKeyColumn: deleteRowPK, + primaryKeyValue: deletingRow[deleteRowPK], + }); + toast.success("Row deleted"); + refetchTableData(); + setDeleteDialogOpen(false); + } catch (err: any) { + toast.error(err.message || "Delete failed"); + } + }; + + const isSearchActive = searchResults !== null; + + return { + // Search + searchTerm, + searchResults, + searchResultCount, + isSearching, + searchPage, + isSearchActive, + handleSearch, + handleSearchChange, + handleSearchPageChange, + handleSearchRefresh, + + // Edit + editDialogOpen, + setEditDialogOpen, + editingRow, + primaryKeyColumn, + handleEditRow, + handleEditSuccess, + + // Delete + deleteDialogOpen, + setDeleteDialogOpen, + deletingRow, + deleteRowPK, + deleteHasPK, + handleDeleteRow, + handleConfirmDelete, + }; +}; \ No newline at end of file diff --git a/src/features/database/hooks/useSelectedDbStats.ts b/src/features/database/hooks/useSelectedDbStats.ts new file mode 100644 index 0000000..9b8aa53 --- /dev/null +++ b/src/features/database/hooks/useSelectedDbStats.ts @@ -0,0 +1,34 @@ +// features/database/hooks/useSelectedDbStats.ts + +import { useQuery } from "@tanstack/react-query"; +import { useCachedDbStats } from "./useCachedData"; +import { bytesToMBString } from "@/lib/bytesToMB"; +import { databaseService } from "@/services/bridge/database"; + +export const useSelectedDbStats = ( + bridgeReady: boolean, + selectedDb: string | null, + isConnected: boolean +) => { + const { getStats: getCachedDbStats, updateCache: updateDbStatsCache } = useCachedDbStats(); + + const { data: selectedDbStats, isLoading: selectedDbStatsLoading } = useQuery({ + queryKey: ["dbStats", selectedDb], + queryFn: async () => { + const result = await databaseService.getDataBaseStats(selectedDb!); + if (result && selectedDb) updateDbStatsCache(selectedDb, result); + return result; + }, + enabled: !!bridgeReady && !!selectedDb && isConnected, + staleTime: 30 * 1000, + }); + + const cachedSelectedDbStats = selectedDb ? getCachedDbStats(selectedDb) : undefined; + const effectiveStats = selectedDbStats || cachedSelectedDbStats; + const isLoadingWithNoCache = selectedDbStatsLoading && !cachedSelectedDbStats; + + return { + tables: isLoadingWithNoCache ? "—" : (effectiveStats?.tables ?? "—"), + size: isLoadingWithNoCache ? "—" : (effectiveStats?.sizeBytes ? bytesToMBString(effectiveStats.sizeBytes) : "—"), + }; +}; \ No newline at end of file diff --git a/src/features/database/hooks/useTableExplorerPanel.ts b/src/features/database/hooks/useTableExplorerPanel.ts new file mode 100644 index 0000000..8033066 --- /dev/null +++ b/src/features/database/hooks/useTableExplorerPanel.ts @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { SelectedTable, TableInfo } from "../types"; + + +interface TablesExplorerPanelProps { + dbId: string; + tables: TableInfo[]; + selectedTable: SelectedTable | null; + selectedSchema: string; + onSelectTable: (tableName: string, schemaName: string) => void; + loading?: boolean; +} + +export function useTableExplorerPanel({ + tables, + selectedTable, +}: TablesExplorerPanelProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [createTableOpen, setCreateTableOpen] = useState(false); + + const [favorites, setFavorites] = useState>( + new Set(JSON.parse(localStorage.getItem('favoriteTables') || '[]')) + ); + const [filter, setFilter] = useState<'all' | 'system' | 'favorites'>('all'); + + const toggleFavorite = (tableName: string) => { + const newFavorites = new Set(favorites); + if (newFavorites.has(tableName)) { + newFavorites.delete(tableName); + } else { + newFavorites.add(tableName); + } + setFavorites(newFavorites); + localStorage.setItem('favoriteTables', JSON.stringify(Array.from(newFavorites))); + }; + + const filteredTables = tables.filter((table) => { + const matchesSearch = table.name.toLowerCase().includes(searchQuery.toLowerCase()); + const isSystemTable = table.name.startsWith('pg_') || table.name.startsWith('information_'); + + if (filter === 'system') return matchesSearch && isSystemTable; + if (filter === 'favorites') return matchesSearch && favorites.has(table.name); + return matchesSearch && !isSystemTable; // 'all' shows non-system tables + }); + + const isSelected = (table: TableInfo) => { + return selectedTable?.name === table.name && selectedTable?.schema === table.schema; + }; + + return { + searchQuery, + setSearchQuery, + createTableOpen, + setCreateTableOpen, + favorites, + setFavorites, + filter, + setFilter, + toggleFavorite, + filteredTables, + isSelected + }; +} \ No newline at end of file diff --git a/src/features/database/hooks/useWelcomeMessage.ts b/src/features/database/hooks/useWelcomeMessage.ts new file mode 100644 index 0000000..2d0d83a --- /dev/null +++ b/src/features/database/hooks/useWelcomeMessage.ts @@ -0,0 +1,40 @@ +import { useState, useEffect } from "react"; + +export function useWelcomeMessage(): string { + const texts = ['Welcome to Relwave', 'Good to see you again!', 'Ready to dive into your data?', 'Your database companion awaits!', 'Let’s explore your data together!']; + const ttl = 3 * 60 * 60 * 1000; // 3 hours + const [message, setMessage] = useState(""); + + useEffect(() => { + const stored = localStorage.getItem("welcomeMessage"); + + if (stored) { + try { + const parsed = JSON.parse(stored); + const now = Date.now(); + + if (now < parsed.expiry) { + setMessage(parsed.value); + return; + } else { + localStorage.removeItem("welcomeMessage"); + } + } catch { + localStorage.removeItem("welcomeMessage"); + } + } + + // Pick a random message + const randomText = texts[Math.floor(Math.random() * texts.length)]; + + const item = { + value: randomText, + expiry: Date.now() + ttl, + }; + + localStorage.setItem("welcomeMessage", JSON.stringify(item)); + setMessage(randomText); + }, [texts, ttl]); + + return message; +} \ No newline at end of file diff --git a/src/types/database.ts b/src/features/database/types.ts similarity index 100% rename from src/types/database.ts rename to src/features/database/types.ts diff --git a/src/components/er-diagram/AnnotationLayer.tsx b/src/features/er-diagram/components/AnnotationLayer.tsx similarity index 96% rename from src/components/er-diagram/AnnotationLayer.tsx rename to src/features/er-diagram/components/AnnotationLayer.tsx index f5c531e..5e331e8 100644 --- a/src/components/er-diagram/AnnotationLayer.tsx +++ b/src/features/er-diagram/components/AnnotationLayer.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import { Tldraw, type Editor } from "tldraw"; import "tldraw/tldraw.css"; -import { bridgeApi } from "@/services/bridgeApi"; +import { projectService } from "@/services/bridge/project"; const ANNOTATION_SAVE_DEBOUNCE_MS = 2000; @@ -26,7 +26,7 @@ export default function AnnotationLayer({ projectId, active }: AnnotationLayerPr if (!editor) return; const snapshot = editor.getSnapshot(); - bridgeApi + projectService .saveProjectAnnotations(projectId, snapshot) .then(() => console.debug("[Annotations] Saved")) .catch((err) => console.warn("[Annotations] Save failed:", err.message)); @@ -40,7 +40,7 @@ export default function AnnotationLayer({ projectId, active }: AnnotationLayerPr // Load saved annotations if (projectId) { - bridgeApi + projectService .getProjectAnnotations(projectId) .then((file) => { if (file?.snapshot && Object.keys(file.snapshot).length > 0) { diff --git a/src/components/er-diagram/ERDiagramContent.tsx b/src/features/er-diagram/components/ERDiagramContent.tsx similarity index 98% rename from src/components/er-diagram/ERDiagramContent.tsx rename to src/features/er-diagram/components/ERDiagramContent.tsx index 2aa82ee..c3c2238 100644 --- a/src/components/er-diagram/ERDiagramContent.tsx +++ b/src/features/er-diagram/components/ERDiagramContent.tsx @@ -18,10 +18,9 @@ import { import { toast } from "sonner"; import { transformSchemaToER } from "@/lib/schemaTransformer"; import { Spinner } from "@/components/ui/spinner"; -import { useERDiagramData } from "@/hooks/useERDiagramData"; -import { bridgeApi } from "@/services/bridgeApi"; -import { ColumnDetails, DatabaseSchemaDetails, ForeignKeyInfo, TableSchemaDetails } from "@/types/database"; -import type { ERNode } from "@/types/project"; +import { useERDiagramData } from "@/features/er-diagram/hooks/useERDiagramData"; +import { ColumnDetails, DatabaseSchemaDetails, ForeignKeyInfo, TableSchemaDetails } from "@/features/database/types"; +import type { ERNode } from "@/features/project/types"; import { Tooltip, TooltipContent, @@ -35,8 +34,9 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; +import { projectService } from "@/services/bridge/project"; -const AnnotationLayer = lazy(() => import("@/components/er-diagram/AnnotationLayer")); +const AnnotationLayer = lazy(() => import("@/features/er-diagram/components/AnnotationLayer")); interface Column extends ColumnDetails { fkRef?: string; // e.g., "public.roles.id" @@ -187,7 +187,7 @@ const ERDiagramContent: React.FC = ({ nodeTypes, projectI height: n.height ?? undefined, })); - bridgeApi + projectService .saveProjectERDiagram(projectId, { nodes: erNodes, zoom: viewport?.zoom, @@ -552,11 +552,10 @@ const ERDiagramContent: React.FC = ({ nodeTypes, projectI setAnnotationMode(prev => !prev)} - className={`p-2 border rounded-md transition-colors ${ - annotationMode - ? "border-primary bg-primary/10 text-primary" - : "border-border hover:bg-muted" - }`} + className={`p-2 border rounded-md transition-colors ${annotationMode + ? "border-primary bg-primary/10 text-primary" + : "border-border hover:bg-muted" + }`} > diff --git a/src/components/er-diagram/ERDiagramPanel.tsx b/src/features/er-diagram/components/ERDiagramPanel.tsx similarity index 78% rename from src/components/er-diagram/ERDiagramPanel.tsx rename to src/features/er-diagram/components/ERDiagramPanel.tsx index 0946a7d..faa2706 100644 --- a/src/components/er-diagram/ERDiagramPanel.tsx +++ b/src/features/er-diagram/components/ERDiagramPanel.tsx @@ -1,7 +1,7 @@ import { ReactFlowProvider } from "reactflow"; import "reactflow/dist/style.css"; -import TableNode from "@/components/er-diagram/TableNode"; -import ERDiagramContent from "@/components/er-diagram/ERDiagramContent"; +import TableNode from "@/features/er-diagram/components/TableNode"; +import ERDiagramContent from "@/features/er-diagram/components/ERDiagramContent"; const nodeTypes = { table: TableNode, diff --git a/src/components/er-diagram/TableNode.tsx b/src/features/er-diagram/components/TableNode.tsx similarity index 98% rename from src/components/er-diagram/TableNode.tsx rename to src/features/er-diagram/components/TableNode.tsx index 2627503..36fbae6 100644 --- a/src/components/er-diagram/TableNode.tsx +++ b/src/features/er-diagram/components/TableNode.tsx @@ -1,4 +1,4 @@ -import { ColumnDetails, ForeignKeyInfo, TableSchemaDetails } from '@/types/database'; +import { ColumnDetails, ForeignKeyInfo, TableSchemaDetails } from '@/features/database/types'; import { ChevronDown, ChevronRight, Key, Table2 } from 'lucide-react'; import React, { useState } from 'react' import { Handle, Position } from 'reactflow'; @@ -72,7 +72,7 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return ( - + {/* Header */} = ({ data }) => { // Build tooltip content const tooltipContent = ( - + {col.name} Type: {col.type} Nullable: {col.nullable ? "Yes" : "No"} @@ -258,7 +258,7 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { Check Constraints {data.checkConstraints.map(chk => ( - + {chk.constraint_name}: {chk.definition} ))} diff --git a/src/components/er-diagram/index.ts b/src/features/er-diagram/components/index.ts similarity index 100% rename from src/components/er-diagram/index.ts rename to src/features/er-diagram/components/index.ts diff --git a/src/hooks/useERDiagramData.ts b/src/features/er-diagram/hooks/useERDiagramData.ts similarity index 92% rename from src/hooks/useERDiagramData.ts rename to src/features/er-diagram/hooks/useERDiagramData.ts index 3a69f2c..9e3658e 100644 --- a/src/hooks/useERDiagramData.ts +++ b/src/features/er-diagram/hooks/useERDiagramData.ts @@ -1,21 +1,22 @@ import { useMemo, useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { useFullSchema } from "@/hooks/useDbQueries"; +import { useFullSchema } from "@/features/project/hooks/useDbQueries"; import { useProjectSchema, useProjectERDiagram, projectKeys, -} from "@/hooks/useProjectQueries"; -import { bridgeApi } from "@/services/bridgeApi"; +} from "@/features/project/hooks/useProjectQueries"; import { snapshotToSchemaDetails, schemaGroupsToSnapshots } from "@/lib/schemaConverters"; import type { DatabaseSchemaDetails, -} from "@/types/database"; +} from "@/features/database/types"; import type { ERDiagramFile, ERNode, -} from "@/types/project"; +} from "@/features/project/types"; +import { projectService } from "@/services/bridge/project"; +import { databaseService } from "@/services/bridge/database"; // ================================================================ // useERDiagramData @@ -126,7 +127,7 @@ export function useERDiagramData( try { // 1. Fetch fresh schema from live database - const freshSchema = await bridgeApi.getSchema(dbId); + const freshSchema = await databaseService.getSchema(dbId); if (!freshSchema?.schemas?.length) { toast.warning("Database returned no schemas"); @@ -136,7 +137,7 @@ export function useERDiagramData( // 2. Convert to snapshots and save to project schema.json // ❗ This does NOT touch er-diagram.json const snapshots = schemaGroupsToSnapshots(freshSchema.schemas); - await bridgeApi.saveProjectSchema(projectId, snapshots); + await projectService.saveProjectSchema(projectId, snapshots); // 3. Invalidate React Query caches to trigger re-render queryClient.invalidateQueries({ diff --git a/src/types/schema.ts b/src/features/er-diagram/types.ts similarity index 100% rename from src/types/schema.ts rename to src/features/er-diagram/types.ts diff --git a/src/components/common/GitStatusBar.tsx b/src/features/git/components/GitStatusBar.tsx similarity index 98% rename from src/components/common/GitStatusBar.tsx rename to src/features/git/components/GitStatusBar.tsx index 44916ea..885652e 100644 --- a/src/components/common/GitStatusBar.tsx +++ b/src/features/git/components/GitStatusBar.tsx @@ -45,16 +45,16 @@ import { useGitCommit, useGitCheckout, useGitCreateBranch, -} from "@/hooks/useGitQueries"; +} from "@/features/git/hooks/useGitQueries"; import { useGitPush, useGitPull, useGitFetch, useGitRemotes, -} from "@/hooks/useGitAdvanced"; +} from "@/features/git/hooks/useGitAdvanced"; import { toast } from "sonner"; -import type { GitBranchInfo } from "@/types/git"; -import RemoteConfigDialog from "./RemoteConfigDialog"; +import type { GitBranchInfo } from "@/features/git/types"; +import RemoteConfigDialog from "../../../components/dev/RemoteConfigDialog"; interface GitStatusBarProps { projectDir: string | null | undefined; @@ -211,7 +211,7 @@ export default function GitStatusBar({ projectDir }: GitStatusBarProps) { - + {branches?.map((b: GitBranchInfo) => ( ({ queryKey: gitAdvancedKeys.remotes(dir ?? ""), - queryFn: () => bridgeApi.gitRemoteList(dir!), + queryFn: () => gitService.gitRemoteList(dir!), enabled: !!dir && ready, staleTime: STALE.remotes, }); @@ -52,7 +52,7 @@ export function useGitRemoteAdd(dir: string | null | undefined) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ name, url }: { name: string; url: string }) => - bridgeApi.gitRemoteAdd(dir!, name, url), + gitService.gitRemoteAdd(dir!, name, url), onSuccess: () => { if (dir) qc.invalidateQueries({ queryKey: gitAdvancedKeys.remotes(dir) }); }, @@ -62,7 +62,7 @@ export function useGitRemoteAdd(dir: string | null | undefined) { export function useGitRemoteRemove(dir: string | null | undefined) { const qc = useQueryClient(); return useMutation({ - mutationFn: (name: string) => bridgeApi.gitRemoteRemove(dir!, name), + mutationFn: (name: string) => gitService.gitRemoteRemove(dir!, name), onSuccess: () => { if (dir) qc.invalidateQueries({ queryKey: gitAdvancedKeys.remotes(dir) }); }, @@ -73,7 +73,7 @@ export function useGitRemoteSetUrl(dir: string | null | undefined) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ name, url }: { name: string; url: string }) => - bridgeApi.gitRemoteSetUrl(dir!, name, url), + gitService.gitRemoteSetUrl(dir!, name, url), onSuccess: () => { if (dir) qc.invalidateQueries({ queryKey: gitAdvancedKeys.remotes(dir) }); }, @@ -93,7 +93,7 @@ export function useGitPush(dir: string | null | undefined) { setUpstream?: boolean; } | void>({ mutationFn: (opts) => - bridgeApi.gitPush( + gitService.gitPush( dir!, opts?.remote, opts?.branch, @@ -111,7 +111,7 @@ export function useGitPull(dir: string | null | undefined) { rebase?: boolean; } | void>({ mutationFn: (opts) => - bridgeApi.gitPull( + gitService.gitPull( dir!, opts?.remote, opts?.branch, @@ -129,7 +129,7 @@ export function useGitFetch(dir: string | null | undefined) { all?: boolean; } | void>({ mutationFn: (opts) => - bridgeApi.gitFetch(dir!, opts?.remote, { prune: opts?.prune, all: opts?.all }), + gitService.gitFetch(dir!, opts?.remote, { prune: opts?.prune, all: opts?.all }), onSuccess: invalidate, }); } @@ -141,7 +141,7 @@ export function useGitFetch(dir: string | null | undefined) { export function useGitRevert(dir: string | null | undefined) { const invalidate = useInvalidateAll(dir); return useMutation({ - mutationFn: (opts) => bridgeApi.gitRevert(dir!, opts.hash, opts.noCommit), + mutationFn: (opts) => gitService.gitRevert(dir!, opts.hash, opts.noCommit), onSuccess: invalidate, }); } diff --git a/src/hooks/useGitQueries.ts b/src/features/git/hooks/useGitQueries.ts similarity index 82% rename from src/hooks/useGitQueries.ts rename to src/features/git/hooks/useGitQueries.ts index b396bd1..946f603 100644 --- a/src/hooks/useGitQueries.ts +++ b/src/features/git/hooks/useGitQueries.ts @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { bridgeApi } from "@/services/bridgeApi"; -import { isBridgeReady } from "@/services/bridgeClient"; -import type { GitStatus, GitFileChange, GitLogEntry, GitBranchInfo } from "@/types/git"; +import { isBridgeReady } from "@/services/bridge/bridgeClient"; +import type { GitStatus, GitFileChange, GitLogEntry, GitBranchInfo } from "@/features/git/types"; +import { gitService } from "@/services/bridge/git"; export const gitKeys = { all: ["git"] as const, @@ -25,7 +25,7 @@ export function useGitStatus(dir: string | null | undefined) { return useQuery({ queryKey: gitKeys.status(dir ?? ""), - queryFn: () => bridgeApi.gitStatus(dir!), + queryFn: () => gitService.gitStatus(dir!), enabled: !!dir && bridgeReady, staleTime: STALE.status, refetchInterval: 15_000, // poll every 15s for live status @@ -40,7 +40,7 @@ export function useGitChanges(dir: string | null | undefined) { return useQuery({ queryKey: gitKeys.changes(dir ?? ""), - queryFn: () => bridgeApi.gitChanges(dir!), + queryFn: () => gitService.gitChanges(dir!), enabled: !!dir && bridgeReady, staleTime: STALE.changes, }); @@ -53,7 +53,7 @@ export function useGitLog(dir: string | null | undefined, count = 20) { return useQuery({ queryKey: gitKeys.log(dir ?? ""), - queryFn: () => bridgeApi.gitLog(dir!, count), + queryFn: () => gitService.gitLog(dir!, count), enabled: !!dir && bridgeReady, staleTime: STALE.log, }); @@ -66,7 +66,7 @@ export function useGitBranches(dir: string | null | undefined) { return useQuery({ queryKey: gitKeys.branches(dir ?? ""), - queryFn: () => bridgeApi.gitBranches(dir!), + queryFn: () => gitService.gitBranches(dir!), enabled: !!dir && bridgeReady, staleTime: STALE.branches, }); @@ -86,7 +86,7 @@ function useInvalidateGit(dir: string | null | undefined) { export function useGitInit(dir: string | null | undefined) { const invalidate = useInvalidateGit(dir); return useMutation({ - mutationFn: () => bridgeApi.gitInit(dir!), + mutationFn: () => gitService.gitInit(dir!), onSuccess: invalidate, }); } @@ -94,7 +94,7 @@ export function useGitInit(dir: string | null | undefined) { export function useGitStageAll(dir: string | null | undefined) { const invalidate = useInvalidateGit(dir); return useMutation({ - mutationFn: () => bridgeApi.gitStageAll(dir!), + mutationFn: () => gitService.gitStageAll(dir!), onSuccess: invalidate, }); } @@ -102,7 +102,7 @@ export function useGitStageAll(dir: string | null | undefined) { export function useGitCommit(dir: string | null | undefined) { const invalidate = useInvalidateGit(dir); return useMutation({ - mutationFn: (message: string) => bridgeApi.gitCommit(dir!, message), + mutationFn: (message: string) => gitService.gitCommit(dir!, message), onSuccess: invalidate, }); } @@ -110,7 +110,7 @@ export function useGitCommit(dir: string | null | undefined) { export function useGitCheckout(dir: string | null | undefined) { const invalidate = useInvalidateGit(dir); return useMutation({ - mutationFn: (branchName: string) => bridgeApi.gitCheckout(dir!, branchName), + mutationFn: (branchName: string) => gitService.gitCheckout(dir!, branchName), onSuccess: invalidate, }); } @@ -118,7 +118,7 @@ export function useGitCheckout(dir: string | null | undefined) { export function useGitCreateBranch(dir: string | null | undefined) { const invalidate = useInvalidateGit(dir); return useMutation({ - mutationFn: (name: string) => bridgeApi.gitCreateBranch(dir!, name), + mutationFn: (name: string) => gitService.gitCreateBranch(dir!, name), onSuccess: invalidate, }); } @@ -126,7 +126,7 @@ export function useGitCreateBranch(dir: string | null | undefined) { export function useGitStash(dir: string | null | undefined) { const invalidate = useInvalidateGit(dir); return useMutation({ - mutationFn: (message?: string) => bridgeApi.gitStash(dir!, message), + mutationFn: (message?: string) => gitService.gitStash(dir!, message), onSuccess: invalidate, }); } @@ -134,7 +134,7 @@ export function useGitStash(dir: string | null | undefined) { export function useGitStashPop(dir: string | null | undefined) { const invalidate = useInvalidateGit(dir); return useMutation({ - mutationFn: () => bridgeApi.gitStashPop(dir!), + mutationFn: () => gitService.gitStashPop(dir!), onSuccess: invalidate, }); } diff --git a/src/types/git.ts b/src/features/git/types.ts similarity index 100% rename from src/types/git.ts rename to src/features/git/types.ts diff --git a/src/components/home/AddConnectionDialog.tsx b/src/features/home/components/AddConnectionDialog.tsx similarity index 99% rename from src/components/home/AddConnectionDialog.tsx rename to src/features/home/components/AddConnectionDialog.tsx index 05fd1c6..f2acd0a 100644 --- a/src/components/home/AddConnectionDialog.tsx +++ b/src/features/home/components/AddConnectionDialog.tsx @@ -21,7 +21,7 @@ import { import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Checkbox } from "@/components/ui/checkbox"; import { parseConnectionUrl } from "@/lib/parseConnectionUrl"; -import { AddConnectionDialogProps, INITIAL_FORM_DATA, ConnectionFormData } from "./types"; +import { AddConnectionDialogProps, INITIAL_FORM_DATA, ConnectionFormData } from "../types"; export function AddConnectionDialog({ open, @@ -83,7 +83,7 @@ export function AddConnectionDialog({ return ( - + diff --git a/src/components/home/ConnectionList.tsx b/src/features/home/components/ConnectionList.tsx similarity index 99% rename from src/components/home/ConnectionList.tsx rename to src/features/home/components/ConnectionList.tsx index b311707..40ad264 100644 --- a/src/components/home/ConnectionList.tsx +++ b/src/features/home/components/ConnectionList.tsx @@ -9,7 +9,7 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { cn } from "@/lib/utils"; -import { ConnectionListProps } from "./types"; +import { ConnectionListProps } from "../types"; export function ConnectionList({ databases, diff --git a/src/components/home/DatabaseDetail.tsx b/src/features/home/components/DatabaseDetail.tsx similarity index 87% rename from src/components/home/DatabaseDetail.tsx rename to src/features/home/components/DatabaseDetail.tsx index 90511f7..fc843b1 100644 --- a/src/components/home/DatabaseDetail.tsx +++ b/src/features/home/components/DatabaseDetail.tsx @@ -17,18 +17,18 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { DatabaseDetailProps } from "./types"; -import { formatRelativeTime } from "./utils"; +import { DatabaseDetailProps } from "../types"; +import { formatRelativeTime } from "../utils"; const DB_COLORS: Record = { - postgresql: { bg: "bg-blue-500/10", text: "text-blue-500" }, - mysql: { bg: "bg-orange-500/10", text: "text-orange-500" }, - mariadb: { bg: "bg-teal-500/10", text: "text-teal-500" }, - sqlite: { bg: "bg-cyan-500/10", text: "text-cyan-500" }, + postgresql: { bg: "bg-blue-500/10", text: "text-blue-500" }, + mysql: { bg: "bg-orange-500/10", text: "text-orange-500" }, + mariadb: { bg: "bg-teal-500/10", text: "text-teal-500" }, + sqlite: { bg: "bg-cyan-500/10", text: "text-cyan-500" }, }; function getDbColors(type: string) { - return DB_COLORS[type] || { bg: "bg-primary/10", text: "text-primary" }; + return DB_COLORS[type] || { bg: "bg-primary/10", text: "text-primary" }; } export function DatabaseDetail({ @@ -163,26 +163,26 @@ export function DatabaseDetail({ {database.type !== "sqlite" && ( - <> - - Host - {database.host} - - - Port - {database.port} - - > + <> + + Host + {database.host} + + + Port + {database.port} + + > )} {database.type === "sqlite" ? "File" : "Database"} {database.database} {database.type !== "sqlite" && ( - - User - {database.user} - + + User + {database.user} + )} Created diff --git a/src/components/home/DeleteDialog.tsx b/src/features/home/components/DeleteDialog.tsx similarity index 95% rename from src/components/home/DeleteDialog.tsx rename to src/features/home/components/DeleteDialog.tsx index dc9d203..a140ba8 100644 --- a/src/components/home/DeleteDialog.tsx +++ b/src/features/home/components/DeleteDialog.tsx @@ -8,7 +8,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { DeleteDialogProps } from "./types"; +import { DeleteDialogProps } from "../types"; export function DeleteDialog({ open, diff --git a/src/components/home/DiscoveredDatabasesCard.tsx b/src/features/home/components/DiscoveredDatabasesCard.tsx similarity index 97% rename from src/components/home/DiscoveredDatabasesCard.tsx rename to src/features/home/components/DiscoveredDatabasesCard.tsx index 0cf5ca0..169bf76 100644 --- a/src/components/home/DiscoveredDatabasesCard.tsx +++ b/src/features/home/components/DiscoveredDatabasesCard.tsx @@ -2,8 +2,8 @@ import { useEffect } from "react"; import { Radar, Plus, Container, Monitor, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { useDiscoveredDatabases } from "@/hooks/useDiscoveredDatabases"; -import { DiscoveredDatabase } from "@/types/database"; +import { useDiscoveredDatabases } from "@/features/database/hooks/useDiscoveredDatabases"; +import { DiscoveredDatabase } from "@/features/database/types"; interface DiscoveredDatabasesCardProps { onAddDatabase: (db: DiscoveredDatabase) => void; diff --git a/src/components/home/WelcomeView.tsx b/src/features/home/components/WelcomeView.tsx similarity index 96% rename from src/components/home/WelcomeView.tsx rename to src/features/home/components/WelcomeView.tsx index 8dac1ad..efcc88e 100644 --- a/src/components/home/WelcomeView.tsx +++ b/src/features/home/components/WelcomeView.tsx @@ -10,10 +10,9 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { WelcomeViewProps } from "./types"; -import { formatRelativeTime } from "./utils"; +import { WelcomeViewProps } from "../types"; +import { formatRelativeTime } from "../utils"; import { DiscoveredDatabasesCard } from "./DiscoveredDatabasesCard"; -import { DiscoveredDatabase } from "@/types/database"; const DB_COLORS: Record = { postgresql: { bg: "bg-blue-500/10", text: "text-blue-500" }, @@ -34,12 +33,14 @@ export function WelcomeView({ totalTables, totalSize, statsLoading, + welcomeMessage, onAddClick, onSelectDb, onDatabaseClick, onDatabaseHover, onDiscoveredDatabaseAdd, }: WelcomeViewProps) { + return ( {/* Welcome Header */} @@ -49,7 +50,7 @@ export function WelcomeView({ - Welcome to RelWave + {welcomeMessage} Select a connection or add a new one diff --git a/src/components/home/index.ts b/src/features/home/components/index.ts similarity index 71% rename from src/components/home/index.ts rename to src/features/home/components/index.ts index f4eb381..d183bf5 100644 --- a/src/components/home/index.ts +++ b/src/features/home/components/index.ts @@ -1,8 +1,8 @@ export { ConnectionList } from "./ConnectionList"; export { DatabaseDetail } from "./DatabaseDetail"; -export { WelcomeView } from "./WelcomeView"; +export { WelcomeView } from "../components/WelcomeView"; export { AddConnectionDialog } from "./AddConnectionDialog"; export { DeleteDialog } from "./DeleteDialog"; export { DiscoveredDatabasesCard } from "./DiscoveredDatabasesCard"; -export * from "./types"; -export * from "./utils"; +export * from "../types"; +export * from "../utils"; diff --git a/src/features/home/hooks/useIndexPage.ts b/src/features/home/hooks/useIndexPage.ts new file mode 100644 index 0000000..969709e --- /dev/null +++ b/src/features/home/hooks/useIndexPage.ts @@ -0,0 +1,223 @@ +// features/home/hooks/useIndexPage.ts + +import { useState, useCallback, useMemo } from "react"; +import { toast } from "sonner"; +import { useNavigate } from "react-router-dom"; +import { useDatabases, useAddDatabase, useDeleteDatabase, usePrefetch } from "@/features/project/hooks/useDbQueries"; +import { ConnectionFormData, REQUIRED_FIELDS, SQLITE_REQUIRED_FIELDS } from "@/features/home/types"; +import { useDatabaseStats } from "../../database/hooks/useDatabaseStats"; +import { useSelectedDbStats } from "../../database/hooks/useSelectedDbStats"; +import { databaseService } from "@/services/bridge/database"; +import { DatabaseConnection } from "@/features/database/types"; +import { useWelcomeMessage } from "@/features/database/hooks/useWelcomeMessage"; + +export const useIndexPage = (bridgeReady: boolean) => { + const navigate = useNavigate(); + + // Database list + const { data: databases = [], isLoading: loading, refetch: refetchDatabases } = useDatabases(); + + // All stats + connection status + const { + status, + totalSize, + totalTables, + connectedCount, + showStatsLoading, + refetchStatus, + } = useDatabaseStats(bridgeReady, databases.length > 0); + + const welcomeMessage = useWelcomeMessage(); + + // Mutations + const addDatabaseMutation = useAddDatabase(); + const deleteDatabaseMutation = useDeleteDatabase(); + const { prefetchTables, prefetchStats } = usePrefetch(); + + // UI state + const [searchQuery, setSearchQuery] = useState(""); + const [selectedDb, setSelectedDb] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [dbToDelete, setDbToDelete] = useState<{ id: string; name: string } | null>(null); + const [prefilledConnectionData, setPrefilledConnectionData] = useState | undefined>(undefined); + + // Selected db derived state + const selectedDatabase = useMemo( + () => (selectedDb ? databases.find((db: DatabaseConnection) => db.id === selectedDb) ?? null : null), + [databases, selectedDb] + ); + const isSelectedConnected = selectedDb ? status.get(selectedDb) === "connected" : false; + + // Selected db stats + const selectedDbStats = useSelectedDbStats(bridgeReady, selectedDb, isSelectedConnected); + + // Filtered + recent databases + const filteredDatabases = useMemo( + () => + databases.filter( + (db: DatabaseConnection) => + db.name.toLowerCase().includes(searchQuery.toLowerCase()) || + db.host.toLowerCase().includes(searchQuery.toLowerCase()) + ), + [databases, searchQuery] + ); + + const recentDatabases = useMemo( + () => + [...databases] + .filter((db) => db.lastAccessedAt) + .sort((a, b) => new Date(b.lastAccessedAt!).getTime() - new Date(a.lastAccessedAt!).getTime()) + .slice(0, 5), + [databases] + ); + + // ---- Bridge Handlers ---- + + const handleAddDatabase = async (formData: ConnectionFormData) => { + const isSQLite = formData.type === "sqlite"; + const requiredFields = isSQLite ? SQLITE_REQUIRED_FIELDS : REQUIRED_FIELDS; + const missing = requiredFields.filter((field) => !formData[field as keyof typeof formData]); + + if (missing.length) { + toast.error("Missing required fields", { description: `Please fill in: ${missing.join(", ")}` }); + return; + } + + try { + const payload = { + ...formData, + port: isSQLite ? 0 : parseInt(formData.port), + sslmode: isSQLite ? "disable" : formData.ssl ? formData.sslmode || "require" : "disable", + }; + await addDatabaseMutation.mutateAsync(payload); + toast.success("Database connection added"); + setIsDialogOpen(false); + await Promise.all([refetchDatabases(), refetchStatus()]); + } catch (err: any) { + toast.error("Failed to add database", { description: err.message }); + } + }; + + const handleDeleteDatabase = async () => { + if (!dbToDelete) return; + try { + await deleteDatabaseMutation.mutateAsync(dbToDelete.id); + toast.success("Database removed"); + setDeleteDialogOpen(false); + setDbToDelete(null); + if (selectedDb === dbToDelete.id) setSelectedDb(null); + refetchDatabases(); + } catch (err: any) { + toast.error("Failed to delete", { description: err.message }); + } + }; + + const handleTestConnection = async (id: string, name: string) => { + try { + const result = await databaseService.testConnection(id); + if (result.ok) { + toast.success("Connected", { description: name }); + refetchStatus(); + } else { + toast.error("Failed", { description: result.message || "Could not connect" }); + } + } catch (err: any) { + toast.error("Failed", { description: err.message }); + } + }; + + // ---- Navigation Handlers ---- + + const handleDatabaseClick = (dbId: string) => { + databaseService.touchDatabase(dbId); + navigate(`/${dbId}`); + }; + + const handleDatabaseHover = (dbId: string) => { + prefetchTables(dbId); + prefetchStats(dbId); + }; + + // ---- Dialog Helpers ---- + + const openDeleteDialog = (id: string, name: string) => { + setDbToDelete({ id, name }); + setDeleteDialogOpen(true); + }; + + const handleDiscoveredDatabaseAdd = useCallback( + (db: { + type: string; + host: string; + port: number; + suggestedName: string; + defaultUser: string; + defaultDatabase: string; + defaultPassword?: string; + }) => { + setPrefilledConnectionData({ + name: db.suggestedName, + type: db.type, + host: db.host, + port: String(db.port), + user: db.defaultUser, + database: db.defaultDatabase, + password: db.defaultPassword || "", + ssl: false, + sslmode: "", + }); + setIsDialogOpen(true); + }, + [] + ); + + const handleDialogClose = (open: boolean) => { + setIsDialogOpen(open); + if (!open) setPrefilledConnectionData(undefined); + }; + + return { + // Data + databases, + filteredDatabases, + recentDatabases, + selectedDatabase, + selectedDbStats, + loading, + welcomeMessage, + + // Status + stats + status, + totalSize, + totalTables, + connectedCount, + showStatsLoading, + isSelectedConnected, + + // Mutation states + isAdding: addDatabaseMutation.isPending, + + // UI state + searchQuery, + setSearchQuery, + selectedDb, + setSelectedDb, + isDialogOpen, + setIsDialogOpen, + deleteDialogOpen, + setDeleteDialogOpen, + dbToDelete, + prefilledConnectionData, + + // Handlers + handleAddDatabase, + handleDeleteDatabase, + handleTestConnection, + handleDatabaseClick, + handleDatabaseHover, + handleDiscoveredDatabaseAdd, + handleDialogClose, + openDeleteDialog, + }; +}; \ No newline at end of file diff --git a/src/components/home/types.ts b/src/features/home/types.ts similarity index 95% rename from src/components/home/types.ts rename to src/features/home/types.ts index fdf051f..82dbe69 100644 --- a/src/components/home/types.ts +++ b/src/features/home/types.ts @@ -1,4 +1,4 @@ -import { DatabaseConnection, DiscoveredDatabase } from "@/types/database"; +import { DatabaseConnection, DiscoveredDatabase } from "@/features/database/types"; export interface ConnectionListProps { databases: DatabaseConnection[]; @@ -36,6 +36,7 @@ export interface WelcomeViewProps { totalTables: number | string; totalSize: string; statsLoading: boolean; + welcomeMessage: string; onAddClick: () => void; onSelectDb: (id: string) => void; onDatabaseClick: (id: string) => void; diff --git a/src/components/home/utils.ts b/src/features/home/utils.ts similarity index 100% rename from src/components/home/utils.ts rename to src/features/home/utils.ts diff --git a/src/components/project/CreateProjectDialog.tsx b/src/features/project/components/CreateProjectDialog.tsx similarity index 98% rename from src/components/project/CreateProjectDialog.tsx rename to src/features/project/components/CreateProjectDialog.tsx index c23b1b2..8aa54d0 100644 --- a/src/components/project/CreateProjectDialog.tsx +++ b/src/features/project/components/CreateProjectDialog.tsx @@ -18,7 +18,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { DatabaseConnection } from "@/types/database"; +import { DatabaseConnection } from "@/features/database/types"; interface CreateProjectDialogProps { open: boolean; diff --git a/src/components/project/DeleteProjectDialog.tsx b/src/features/project/components/DeleteProjectDialog.tsx similarity index 100% rename from src/components/project/DeleteProjectDialog.tsx rename to src/features/project/components/DeleteProjectDialog.tsx diff --git a/src/components/project/ImportProjectDialog.tsx b/src/features/project/components/ImportProjectDialog.tsx similarity index 97% rename from src/components/project/ImportProjectDialog.tsx rename to src/features/project/components/ImportProjectDialog.tsx index dead276..cc2e222 100644 --- a/src/components/project/ImportProjectDialog.tsx +++ b/src/features/project/components/ImportProjectDialog.tsx @@ -19,8 +19,9 @@ import { SelectValue, } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; -import { bridgeApi } from "@/services/bridgeApi"; -import type { ScanImportResult } from "@/types/project"; +import type { ScanImportResult } from "@/features/project/types"; +import { projectService } from "@/services/bridge/project"; +import { databaseService } from "@/services/bridge/database"; // ========================================== // Types @@ -110,7 +111,7 @@ export function ImportProjectDialog({ setStep("scanning"); try { - const result = await bridgeApi.scanImportSource(selectedPath); + const result = await projectService.scanImportSource(selectedPath); setScanResult(result); // Pre-fill DB form from scan results @@ -158,7 +159,7 @@ export function ImportProjectDialog({ } // 2. Create the database connection first - const db = await bridgeApi.addDatabase({ + const db = await databaseService.addDatabase({ name: dbForm.name, type: dbForm.type, host: isSQLite ? "" : dbForm.host, @@ -171,7 +172,7 @@ export function ImportProjectDialog({ createdDbId = db.id; // 3. Import the project with a valid databaseId - const project = await bridgeApi.importProject({ + const project = await projectService.importProject({ sourcePath: selectedPath, databaseId: db.id, }); @@ -185,7 +186,7 @@ export function ImportProjectDialog({ // Roll back the database connection if it was created but import failed if (createdDbId) { try { - await bridgeApi.deleteDatabase(createdDbId); + await databaseService.deleteDatabase(createdDbId); } catch { // Best-effort cleanup — don't mask the original error } @@ -206,7 +207,7 @@ export function ImportProjectDialog({ return ( - + diff --git a/src/components/project/ProjectDetailView.tsx b/src/features/project/components/ProjectDetailView.tsx similarity index 99% rename from src/components/project/ProjectDetailView.tsx rename to src/features/project/components/ProjectDetailView.tsx index 5fd1f7f..0b1450a 100644 --- a/src/components/project/ProjectDetailView.tsx +++ b/src/features/project/components/ProjectDetailView.tsx @@ -19,7 +19,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { ProjectSummary } from "@/types/project"; +import { ProjectSummary } from "@/features/project/types"; interface ProjectDetailViewProps { project: ProjectSummary; diff --git a/src/components/project/ProjectList.tsx b/src/features/project/components/ProjectList.tsx similarity index 99% rename from src/components/project/ProjectList.tsx rename to src/features/project/components/ProjectList.tsx index c8cf17d..59e60bf 100644 --- a/src/components/project/ProjectList.tsx +++ b/src/features/project/components/ProjectList.tsx @@ -16,7 +16,7 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { cn } from "@/lib/utils"; -import { ProjectSummary } from "@/types/project"; +import { ProjectSummary } from "@/features/project/types"; interface ProjectListProps { projects: ProjectSummary[]; diff --git a/src/features/project/components/ProjectsEmptyState.tsx b/src/features/project/components/ProjectsEmptyState.tsx new file mode 100644 index 0000000..b8d6ab6 --- /dev/null +++ b/src/features/project/components/ProjectsEmptyState.tsx @@ -0,0 +1,32 @@ +// features/project/components/ProjectsEmptyState.tsx + +import { FolderOpen, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface ProjectsEmptyStateProps { + hasProjects: boolean; + onCreateClick: () => void; +} + +export const ProjectsEmptyState = ({ hasProjects, onCreateClick }: ProjectsEmptyStateProps) => ( + + + + + + + Projects + + Save database details, ER diagrams & queries offline + + + + + {!hasProjects && ( + + + Create Your First Project + + )} + +); \ No newline at end of file diff --git a/src/components/project/index.ts b/src/features/project/components/index.ts similarity index 100% rename from src/components/project/index.ts rename to src/features/project/components/index.ts diff --git a/src/hooks/useDbQueries.ts b/src/features/project/hooks/useDbQueries.ts similarity index 87% rename from src/hooks/useDbQueries.ts rename to src/features/project/hooks/useDbQueries.ts index dc597ab..0ed72da 100644 --- a/src/hooks/useDbQueries.ts +++ b/src/features/project/hooks/useDbQueries.ts @@ -1,7 +1,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { bridgeApi } from "@/services/bridgeApi"; -import { DatabaseConnection, TableInfo, DatabaseStats } from "@/types/database"; -import { isBridgeReady } from "@/services/bridgeClient"; +import { TableInfo } from "@/features/database/types"; +import { isBridgeReady } from "@/services/bridge/bridgeClient"; +import { databaseService } from "@/services/bridge/database"; +import { queryService } from "@/services/bridge/query"; // ============================================ // Query Keys - Centralized for cache management @@ -52,7 +53,7 @@ export function useDatabases() { return useQuery({ queryKey: queryKeys.databases, - queryFn: () => bridgeApi.listDatabases(), + queryFn: () => databaseService.listDatabases(), staleTime: STALE_TIMES.databases, gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes enabled: bridgeReady, // Only fetch when bridge is ready @@ -65,7 +66,7 @@ export function useDatabases() { export function useDatabase(id: string | undefined) { return useQuery({ queryKey: queryKeys.database(id!), - queryFn: () => bridgeApi.getDatabase(id!), + queryFn: () => databaseService.getDatabase(id!), enabled: !!id, staleTime: STALE_TIMES.databases, }); @@ -77,7 +78,7 @@ export function useDatabase(id: string | undefined) { export function useMigrations(dbId: string | undefined) { return useQuery({ queryKey: ["migrations", dbId] as const, - queryFn: () => bridgeApi.getMigrations(dbId!), + queryFn: () => databaseService.getMigrations(dbId!), enabled: !!dbId, staleTime: 30 * 1000, // 30 seconds gcTime: 5 * 60 * 1000, @@ -97,7 +98,7 @@ export function useTables(dbId: string | undefined, schema?: string) { return useQuery({ queryKey: queryKeys.tables(dbId!, schema), queryFn: async () => { - const result = await bridgeApi.listTables(dbId!, schema); + const result = await databaseService.listTables(dbId!, schema); return result.map((item: any): TableInfo => ({ schema: item.schema || "public", name: item.name || "unknown", @@ -116,7 +117,7 @@ export function useTables(dbId: string | undefined, schema?: string) { export function useSchemaNames(dbId: string | undefined) { return useQuery({ queryKey: ["schemaNames", dbId] as const, - queryFn: () => bridgeApi.listSchemas(dbId!), + queryFn: () => databaseService.listSchemas(dbId!), enabled: !!dbId, staleTime: STALE_TIMES.schemas, }); @@ -136,7 +137,7 @@ export function useTableData( ) { return useQuery({ queryKey: queryKeys.tableData(dbId!, schema!, table!, page, pageSize), - queryFn: () => bridgeApi.fetchTableData(dbId!, schema!, table!, pageSize, page), + queryFn: () => queryService.fetchTableData(dbId!, schema!, table!, pageSize, page), enabled: !!dbId && !!schema && !!table, staleTime: STALE_TIMES.tableData, placeholderData: (previousData) => previousData, // Keep showing old data while fetching @@ -154,7 +155,7 @@ export function useTableData( export function useDbStats(dbId: string | undefined) { return useQuery({ queryKey: queryKeys.stats(dbId!), - queryFn: () => bridgeApi.getDataBaseStats(dbId!), + queryFn: () => databaseService.getDataBaseStats(dbId!), enabled: !!dbId, staleTime: STALE_TIMES.stats, refetchInterval: 60 * 1000, // Auto-refresh every minute @@ -167,7 +168,7 @@ export function useDbStats(dbId: string | undefined) { export function useSchemas(dbId: string | undefined) { return useQuery({ queryKey: queryKeys.schemas(dbId!), - queryFn: () => bridgeApi.getSchema(dbId!), + queryFn: () => databaseService.getSchema(dbId!), enabled: !!dbId, staleTime: STALE_TIMES.schemas, }); @@ -183,7 +184,7 @@ export function useFullSchema(dbId: string | undefined) { return useQuery({ queryKey: ["fullSchema", dbId] as const, - queryFn: () => bridgeApi.getSchema(dbId!), + queryFn: () => databaseService.getSchema(dbId!), enabled: !!dbId && bridgeReady, staleTime: STALE_TIMES.schemas, // 5 minutes - schema structure rarely changes gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes @@ -200,7 +201,7 @@ export function usePrimaryKeys( ) { return useQuery({ queryKey: queryKeys.primaryKeys(dbId!, schema!, table!), - queryFn: () => bridgeApi.getPrimaryKeys(dbId!, schema!, table!), + queryFn: () => databaseService.getPrimaryKeys(dbId!, schema!, table!), enabled: !!dbId && !!schema && !!table, staleTime: STALE_TIMES.tableDetails, }); @@ -218,7 +219,7 @@ export function useAddDatabase() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: bridgeApi.addDatabase.bind(bridgeApi), + mutationFn: databaseService.addDatabase.bind(databaseService), onSuccess: () => { // Invalidate and refetch database list queryClient.invalidateQueries({ queryKey: queryKeys.databases }); @@ -233,7 +234,7 @@ export function useUpdateDatabase() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (params: any) => bridgeApi.updateDatabase(params), + mutationFn: (params: any) => databaseService.updateDatabase(params), onSuccess: (_, params) => { queryClient.invalidateQueries({ queryKey: queryKeys.databases }); if (params.id) { @@ -250,7 +251,7 @@ export function useDeleteDatabase() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (id: string) => bridgeApi.deleteDatabase(id), + mutationFn: (id: string) => databaseService.deleteDatabase(id), onSuccess: (_, id) => { queryClient.invalidateQueries({ queryKey: queryKeys.databases }); // Remove all cached data for this database @@ -265,7 +266,7 @@ export function useDeleteDatabase() { */ export function useTestConnection() { return useMutation({ - mutationFn: bridgeApi.testConnection.bind(bridgeApi), + mutationFn: databaseService.testConnection.bind(databaseService), }); } @@ -286,7 +287,7 @@ export function usePrefetch() { prefetchTables: (dbId: string) => { queryClient.prefetchQuery({ queryKey: queryKeys.tables(dbId), - queryFn: () => bridgeApi.listTables(dbId), + queryFn: () => databaseService.listTables(dbId), staleTime: STALE_TIMES.tables, }); }, @@ -303,7 +304,7 @@ export function usePrefetch() { ) => { queryClient.prefetchQuery({ queryKey: queryKeys.tableData(dbId, schema, table, currentPage + 1, pageSize), - queryFn: () => bridgeApi.fetchTableData(dbId, schema, table, pageSize, currentPage + 1), + queryFn: () => queryService.fetchTableData(dbId, schema, table, pageSize, currentPage + 1), staleTime: STALE_TIMES.tableData, }); }, @@ -314,7 +315,7 @@ export function usePrefetch() { prefetchStats: (dbId: string) => { queryClient.prefetchQuery({ queryKey: queryKeys.stats(dbId), - queryFn: () => bridgeApi.getDataBaseStats(dbId), + queryFn: () => databaseService.getDataBaseStats(dbId), staleTime: STALE_TIMES.stats, }); }, @@ -325,7 +326,7 @@ export function usePrefetch() { prefetchSchema: (dbId: string) => { queryClient.prefetchQuery({ queryKey: ["fullSchema", dbId], - queryFn: () => bridgeApi.getSchema(dbId), + queryFn: () => databaseService.getSchema(dbId), staleTime: STALE_TIMES.schemas, }); }, diff --git a/src/hooks/useProjectQueries.ts b/src/features/project/hooks/useProjectQueries.ts similarity index 84% rename from src/hooks/useProjectQueries.ts rename to src/features/project/hooks/useProjectQueries.ts index e15fb6c..b3fa466 100644 --- a/src/hooks/useProjectQueries.ts +++ b/src/features/project/hooks/useProjectQueries.ts @@ -1,6 +1,5 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { bridgeApi } from "@/services/bridgeApi"; -import { isBridgeReady } from "@/services/bridgeClient"; +import { isBridgeReady } from "@/services/bridge/bridgeClient"; import { CreateProjectParams, UpdateProjectParams, @@ -8,7 +7,8 @@ import { ERNode, ImportProjectParams, ProjectMetadata, -} from "@/types/project"; +} from "@/features/project/types"; +import { projectService } from "@/services/bridge/project"; // ============================================ // Query Keys @@ -45,7 +45,7 @@ export function useProjects() { return useQuery({ queryKey: projectKeys.all, - queryFn: () => bridgeApi.listProjects(), + queryFn: () => projectService.listProjects(), staleTime: STALE_TIMES.list, gcTime: 10 * 60 * 1000, enabled: bridgeReady, @@ -58,7 +58,7 @@ export function useProjects() { export function useProject(projectId: string | undefined) { return useQuery({ queryKey: projectKeys.detail(projectId!), - queryFn: () => bridgeApi.getProject(projectId!), + queryFn: () => projectService.getProject(projectId!), staleTime: STALE_TIMES.detail, enabled: !!projectId, }); @@ -70,7 +70,7 @@ export function useProject(projectId: string | undefined) { export function useProjectByDatabaseId(databaseId: string | undefined) { return useQuery({ queryKey: projectKeys.byDatabaseId(databaseId!), - queryFn: () => bridgeApi.getProjectByDatabaseId(databaseId!), + queryFn: () => projectService.getProjectByDatabaseId(databaseId!), staleTime: STALE_TIMES.detail, enabled: !!databaseId, }); @@ -82,7 +82,7 @@ export function useProjectByDatabaseId(databaseId: string | undefined) { export function useProjectDir(projectId: string | null | undefined) { return useQuery({ queryKey: projectKeys.dir(projectId!), - queryFn: () => bridgeApi.getProjectDir(projectId!), + queryFn: () => projectService.getProjectDir(projectId!), staleTime: Infinity, // path never changes for a given project gcTime: 30 * 60 * 1000, enabled: !!projectId, @@ -96,7 +96,7 @@ export function useCreateProject() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (params: CreateProjectParams) => bridgeApi.createProject(params), + mutationFn: (params: CreateProjectParams) => projectService.createProject(params), onSuccess: () => { queryClient.invalidateQueries({ queryKey: projectKeys.all }); }, @@ -110,7 +110,7 @@ export function useImportProject() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (params: ImportProjectParams) => bridgeApi.importProject(params), + mutationFn: (params: ImportProjectParams) => projectService.importProject(params), onSuccess: () => { queryClient.invalidateQueries({ queryKey: projectKeys.all }); }, @@ -124,7 +124,7 @@ export function useUpdateProject() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (params: UpdateProjectParams) => bridgeApi.updateProject(params), + mutationFn: (params: UpdateProjectParams) => projectService.updateProject(params), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: projectKeys.all }); queryClient.invalidateQueries({ @@ -141,7 +141,7 @@ export function useDeleteProject() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (projectId: string) => bridgeApi.deleteProject(projectId), + mutationFn: (projectId: string) => projectService.deleteProject(projectId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: projectKeys.all }); }, @@ -154,7 +154,7 @@ export function useDeleteProject() { export function useProjectSchema(projectId: string | undefined) { return useQuery({ queryKey: projectKeys.schema(projectId!), - queryFn: () => bridgeApi.getProjectSchema(projectId!), + queryFn: () => projectService.getProjectSchema(projectId!), staleTime: STALE_TIMES.schema, enabled: !!projectId, }); @@ -170,7 +170,7 @@ export function useSaveProjectSchema() { }: { projectId: string; schemas: SchemaSnapshot[]; - }) => bridgeApi.saveProjectSchema(projectId, schemas), + }) => projectService.saveProjectSchema(projectId, schemas), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: projectKeys.schema(variables.projectId), @@ -185,7 +185,7 @@ export function useSaveProjectSchema() { export function useProjectERDiagram(projectId: string | undefined) { return useQuery({ queryKey: projectKeys.erDiagram(projectId!), - queryFn: () => bridgeApi.getProjectERDiagram(projectId!), + queryFn: () => projectService.getProjectERDiagram(projectId!), staleTime: STALE_TIMES.erDiagram, enabled: !!projectId, }); @@ -207,7 +207,7 @@ export function useSaveProjectERDiagram() { zoom?: number; panX?: number; panY?: number; - }) => bridgeApi.saveProjectERDiagram(projectId, { nodes, zoom, panX, panY }), + }) => projectService.saveProjectERDiagram(projectId, { nodes, zoom, panX, panY }), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: projectKeys.erDiagram(variables.projectId), @@ -222,7 +222,7 @@ export function useSaveProjectERDiagram() { export function useProjectQueries(projectId: string | undefined) { return useQuery({ queryKey: projectKeys.queries(projectId!), - queryFn: () => bridgeApi.getProjectQueries(projectId!), + queryFn: () => projectService.getProjectQueries(projectId!), staleTime: STALE_TIMES.queries, enabled: !!projectId, }); @@ -242,7 +242,7 @@ export function useAddProjectQuery() { name: string; sql: string; description?: string; - }) => bridgeApi.addProjectQuery(projectId, { name, sql, description }), + }) => projectService.addProjectQuery(projectId, { name, sql, description }), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: projectKeys.queries(variables.projectId), @@ -265,7 +265,7 @@ export function useUpdateProjectQuery() { name?: string; sql?: string; description?: string; - }) => bridgeApi.updateProjectQuery(projectId, queryId, updates), + }) => projectService.updateProjectQuery(projectId, queryId, updates), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: projectKeys.queries(variables.projectId), @@ -284,7 +284,7 @@ export function useDeleteProjectQuery() { }: { projectId: string; queryId: string; - }) => bridgeApi.deleteProjectQuery(projectId, queryId), + }) => projectService.deleteProjectQuery(projectId, queryId), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: projectKeys.queries(variables.projectId), @@ -299,7 +299,7 @@ export function useDeleteProjectQuery() { export function useExportProject(projectId: string | undefined) { return useQuery({ queryKey: projectKeys.export(projectId!), - queryFn: () => bridgeApi.exportProject(projectId!), + queryFn: () => projectService.exportProject(projectId!), enabled: false, // Manual trigger only }); } diff --git a/src/hooks/useProjectSync.ts b/src/features/project/hooks/useProjectSync.ts similarity index 90% rename from src/hooks/useProjectSync.ts rename to src/features/project/hooks/useProjectSync.ts index c42ee8d..6a629b2 100644 --- a/src/hooks/useProjectSync.ts +++ b/src/features/project/hooks/useProjectSync.ts @@ -1,9 +1,9 @@ import { useEffect, useRef, useCallback } from "react"; -import { bridgeApi } from "@/services/bridgeApi"; -import { useProjectByDatabaseId } from "@/hooks/useProjectQueries"; +import { useProjectByDatabaseId } from "@/features/project/hooks/useProjectQueries"; import { schemaGroupsToSnapshots } from "@/lib/schemaConverters"; -import type { DatabaseSchemaDetails } from "@/types/database"; -import type { ERNode } from "@/types/project"; +import type { DatabaseSchemaDetails } from "@/features/database/types"; +import type { ERNode } from "@/features/project/types"; +import { projectService } from "@/services/bridge/project"; // ========================================== // Hook: useProjectSync @@ -52,7 +52,7 @@ export function useProjectSync( const snapshots = schemaGroupsToSnapshots(schemaData.schemas); // Fire-and-forget — sync in the background without blocking UI - bridgeApi + projectService .saveProjectSchema(projectId, snapshots) .then(() => { lastSyncedSchemaRef.current = fingerprint; @@ -69,7 +69,7 @@ export function useProjectSync( const saveERDiagram = useCallback( (nodes: ERNode[], zoom?: number, panX?: number, panY?: number) => { if (!projectId) return; - bridgeApi + projectService .saveProjectERDiagram(projectId, { nodes, zoom, panX, panY }) .then(() => { console.debug("[ProjectSync] ER diagram synced for project", projectId); diff --git a/src/features/project/hooks/useProjectsPage.ts b/src/features/project/hooks/useProjectsPage.ts new file mode 100644 index 0000000..bb6cc69 --- /dev/null +++ b/src/features/project/hooks/useProjectsPage.ts @@ -0,0 +1,184 @@ +// features/project/hooks/useProjectsPage.ts + +import { useState, useMemo } from "react"; +import { toast } from "sonner"; +import { useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { useDatabases, queryKeys } from "@/features/project/hooks/useDbQueries"; +import { + useProjects, + useCreateProject, + useDeleteProject, + useProjectSchema, + useProjectERDiagram, + useProjectQueries, + projectKeys, +} from "@/features/project/hooks/useProjectQueries"; +import { projectService } from "@/services/bridge/project"; +import { ProjectSummary } from "../types"; + + +// ---- Types ---- + +interface CreateProjectInput { + databaseId: string; + name: string; + description?: string; + defaultSchema?: string; +} + +interface ProjectToDelete { + id: string; + name: string; +} + +// ---- Hook ---- + +export const useProjectsPage = () => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + // Data + const { data: projects = [], isLoading: projectsLoading } = useProjects(); + const { data: databases = [] } = useDatabases(); + + // Mutations + const createProjectMutation = useCreateProject(); + const deleteProjectMutation = useDeleteProject(); + + // UI State + const [searchQuery, setSearchQuery] = useState(""); + const [selectedProject, setSelectedProject] = useState(null); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isImportOpen, setIsImportOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [projectToDelete, setProjectToDelete] = useState(null); + + // Sub-resource queries for selected project + const { data: schemaData } = useProjectSchema(selectedProject ?? undefined); + const { data: erData } = useProjectERDiagram(selectedProject ?? undefined); + const { data: queriesData } = useProjectQueries(selectedProject ?? undefined); + + // Derived state + const filteredProjects = useMemo( + () => + projects.filter( + (p: ProjectSummary) => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (p.description ?? "").toLowerCase().includes(searchQuery.toLowerCase()) + ), + [projects, searchQuery] + ); + + const selectedProjectData = useMemo( + () => projects.find((p: ProjectSummary) => p.id === selectedProject) ?? null, + [projects, selectedProject] + ); + + // ---- Bridge Handlers (belong in hook) ---- + + const handleCreate = async (data: CreateProjectInput) => { + try { + const created = await createProjectMutation.mutateAsync(data); + toast.success("Project created", { description: (created as ProjectSummary).name }); + setIsCreateOpen(false); + setSelectedProject((created as ProjectSummary).id); + } catch (err: any) { + toast.error("Failed to create project", { description: err.message }); + } + }; + + const handleDelete = async () => { + if (!projectToDelete) return; + try { + await deleteProjectMutation.mutateAsync(projectToDelete.id); + toast.success("Project deleted"); + setDeleteDialogOpen(false); + setProjectToDelete(null); + if (selectedProject === projectToDelete.id) setSelectedProject(null); + } catch (err: any) { + toast.error("Failed to delete", { description: err.message }); + } + }; + + const handleExport = async (projectId: string) => { + try { + const bundle = await projectService.exportProject(projectId); + if (!bundle) { + toast.error("Project not found"); + return; + } + const blob = new Blob([JSON.stringify(bundle, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${bundle.metadata.name.replace(/\s+/g, "-").toLowerCase()}-export.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + toast.success("Project exported"); + } catch (err: any) { + toast.error("Export failed", { description: err.message }); + } + }; + + const handleImportComplete = (projectId: string, projectName: string) => { + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.databases }); + toast.success("Project imported", { description: projectName }); + setSelectedProject(projectId); + }; + + // ---- Navigation Handler (can stay in page but fine here too) ---- + + const handleOpen = (projectId: string) => { + const project = projects.find((p: ProjectSummary) => p.id === projectId); + if (project) navigate(`/${project.databaseId}`); + }; + + // ---- Delete dialog helpers ---- + + const openDeleteDialog = (id: string, name: string) => { + setProjectToDelete({ id, name }); + setDeleteDialogOpen(true); + }; + + return { + // Data + projects, + databases, + filteredProjects, + selectedProjectData, + projectsLoading, + + // Sub-resource data + schemaData, + erData, + queriesData, + + // Mutation states + isCreating: createProjectMutation.isPending, + + // UI state + searchQuery, + setSearchQuery, + selectedProject, + setSelectedProject, + isCreateOpen, + setIsCreateOpen, + isImportOpen, + setIsImportOpen, + deleteDialogOpen, + setDeleteDialogOpen, + projectToDelete, + + // Handlers + handleCreate, + handleDelete, + handleExport, + handleImportComplete, + handleOpen, + openDeleteDialog, + }; +}; \ No newline at end of file diff --git a/src/types/project.ts b/src/features/project/types.ts similarity index 100% rename from src/types/project.ts rename to src/features/project/types.ts diff --git a/src/components/query-builder/BuilderHeader.tsx b/src/features/query-builder/components/BuilderHeader.tsx similarity index 98% rename from src/components/query-builder/BuilderHeader.tsx rename to src/features/query-builder/components/BuilderHeader.tsx index 9e212e6..506e5b7 100644 --- a/src/components/query-builder/BuilderHeader.tsx +++ b/src/features/query-builder/components/BuilderHeader.tsx @@ -1,6 +1,6 @@ import { Play, Square, Database, Loader2, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { BuilderHeaderProps } from "./types"; +import { BuilderHeaderProps } from "../types"; export function BuilderHeader({ databaseName, diff --git a/src/components/query-builder/BuilderSidebar.tsx b/src/features/query-builder/components/BuilderSidebar.tsx similarity index 99% rename from src/components/query-builder/BuilderSidebar.tsx rename to src/features/query-builder/components/BuilderSidebar.tsx index acd1f0e..64c4227 100644 --- a/src/components/query-builder/BuilderSidebar.tsx +++ b/src/features/query-builder/components/BuilderSidebar.tsx @@ -26,8 +26,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; -import { QueryFilter, ColumnOption, QueryHistoryItem, TableSchema } from "./types"; -import { BuilderSidebarProps } from "./types"; +import { QueryFilter, BuilderSidebarProps } from "../types"; export function BuilderSidebar({ diff --git a/src/components/query-builder/BuilderStatusBar.tsx b/src/features/query-builder/components/BuilderStatusBar.tsx similarity index 100% rename from src/components/query-builder/BuilderStatusBar.tsx rename to src/features/query-builder/components/BuilderStatusBar.tsx diff --git a/src/components/query-builder/DiagramCanvas.tsx b/src/features/query-builder/components/DiagramCanvas.tsx similarity index 96% rename from src/components/query-builder/DiagramCanvas.tsx rename to src/features/query-builder/components/DiagramCanvas.tsx index cf321df..b93fcca 100644 --- a/src/components/query-builder/DiagramCanvas.tsx +++ b/src/features/query-builder/components/DiagramCanvas.tsx @@ -1,7 +1,7 @@ import { Node, Edge } from "reactflow"; import ReactFlow, { Background, BackgroundVariant, Controls } from "reactflow"; import { Table2, X } from "lucide-react"; -import TableNode from "@/components/er-diagram/TableNode"; +import { TableNode } from "@/features/er-diagram/components"; const nodeTypes = { table: TableNode, @@ -35,7 +35,7 @@ export function DiagramCanvas({ onCloseMenu, }: DiagramCanvasProps) { return ( - + {/* Edge Join Type Menu */} {menuPosition && selectedEdge && ( <> @@ -44,7 +44,7 @@ export function DiagramCanvas({ onClick={onCloseMenu} /> diff --git a/src/components/query-builder/QueryBuilderPanel.tsx b/src/features/query-builder/components/QueryBuilderPanel.tsx similarity index 95% rename from src/components/query-builder/QueryBuilderPanel.tsx rename to src/features/query-builder/components/QueryBuilderPanel.tsx index 180542f..1666645 100644 --- a/src/components/query-builder/QueryBuilderPanel.tsx +++ b/src/features/query-builder/components/QueryBuilderPanel.tsx @@ -8,23 +8,20 @@ import { } from "reactflow"; import "reactflow/dist/style.css"; import { toast } from "sonner"; -import { useFullSchema } from "@/hooks/useDbQueries"; -import { useQueryHistory } from "@/hooks/useQueryHistory"; -import { useDatabase } from "@/hooks/useDbQueries"; +import { useFullSchema } from "@/features/project/hooks/useDbQueries"; +import { useQueryHistory } from "@/features/query-builder/hooks/useQueryHistory"; +import { useDatabase } from "@/features/project/hooks/useDbQueries"; import { Spinner } from "@/components/ui/spinner"; -import { useBridgeQuery } from "@/hooks/useBridgeQuery"; -import { TableRow } from "@/types/database"; -import { bridgeApi } from "@/services/bridgeApi"; - -// Sub-components +import { useBridgeQuery } from "@/services/bridge/useBridgeQuery"; +import { TableRow } from "@/features/database/types"; import { BuilderHeader } from "./BuilderHeader"; import { BuilderSidebar } from "./BuilderSidebar"; import { DiagramCanvas } from "./DiagramCanvas"; import { SQLResultsPanel } from "./SQLResultsPanel"; import { BuilderStatusBar } from "./BuilderStatusBar"; - -// Types -import { QueryFilter, ColumnOption } from "./types"; +import { QueryFilter, ColumnOption } from "../types"; +import { sessionService } from "@/services/bridge/session"; +import { queryService } from "@/services/bridge/query"; interface QueryBuilderPanelProps { dbId: string; @@ -275,10 +272,10 @@ const QueryBuilderPanel = ({ dbId }: QueryBuilderPanelProps) => { setQueryProgress(null); setIsExecuting(true); - const sessionId = await bridgeApi.createSession(); + const sessionId = await sessionService.createSession(); setQuerySessionId(sessionId); - await bridgeApi.runQuery({ + await queryService.runQuery({ sessionId, dbId, sql: generatedSQL, @@ -292,7 +289,7 @@ const QueryBuilderPanel = ({ dbId }: QueryBuilderPanelProps) => { const handleCancelQuery = useCallback(async () => { if (!querySessionId) return; try { - await bridgeApi.cancelSession(querySessionId); + await sessionService.cancelSession(querySessionId); toast.info("Query cancelled"); } catch (error: any) { toast.error("Failed to cancel", { description: error.message }); diff --git a/src/components/query-builder/SQLResultsPanel.tsx b/src/features/query-builder/components/SQLResultsPanel.tsx similarity index 95% rename from src/components/query-builder/SQLResultsPanel.tsx rename to src/features/query-builder/components/SQLResultsPanel.tsx index cb8c797..ede0628 100644 --- a/src/components/query-builder/SQLResultsPanel.tsx +++ b/src/features/query-builder/components/SQLResultsPanel.tsx @@ -1,7 +1,7 @@ import { Code } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { DataTable } from "@/components/common/DataTable"; -import { TableRow } from "@/types/database"; +import { DataTable } from "@/components/shared/DataTable"; +import { TableRow } from "@/features/database/types"; interface SQLResultsPanelProps { generatedSQL: string; diff --git a/src/components/query-builder/index.ts b/src/features/query-builder/components/index.ts similarity index 96% rename from src/components/query-builder/index.ts rename to src/features/query-builder/components/index.ts index 707c4ba..00c6c2e 100644 --- a/src/components/query-builder/index.ts +++ b/src/features/query-builder/components/index.ts @@ -20,4 +20,4 @@ export type { DiagramCanvasProps, SQLResultsPanelProps, BuilderStatusBarProps, -} from './types'; +} from '../types'; diff --git a/src/hooks/useQueryHistory.ts b/src/features/query-builder/hooks/useQueryHistory.ts similarity index 100% rename from src/hooks/useQueryHistory.ts rename to src/features/query-builder/hooks/useQueryHistory.ts diff --git a/src/components/query-builder/types.ts b/src/features/query-builder/types.ts similarity index 100% rename from src/components/query-builder/types.ts rename to src/features/query-builder/types.ts diff --git a/src/components/schema-explorer/AddIndexesDialog.tsx b/src/features/schema-explorer/components/AddIndexesDialog.tsx similarity index 62% rename from src/components/schema-explorer/AddIndexesDialog.tsx rename to src/features/schema-explorer/components/AddIndexesDialog.tsx index 3e5f8ba..d130a6e 100644 --- a/src/components/schema-explorer/AddIndexesDialog.tsx +++ b/src/features/schema-explorer/components/AddIndexesDialog.tsx @@ -1,5 +1,3 @@ -import { useState } from "react"; -import { toast } from "sonner"; import { Dialog, DialogContent, @@ -12,8 +10,7 @@ import { Button } from "@/components/ui/button"; import { Loader2, Database, Plus } from "lucide-react"; import { Label } from "@/components/ui/label"; import IndexRow from "./IndexRow"; -import { CreateIndexDefinition } from "@/types/database"; -import { bridgeApi } from "@/services/bridgeApi"; +import { useAddIndexDialog } from "../hooks/useAddIndexDialog"; interface AddIndexesDialogProps { open: boolean; @@ -34,115 +31,22 @@ export default function AddIndexesDialog({ availableColumns, onSuccess, }: AddIndexesDialogProps) { - const [indexes, setIndexes] = useState([]); - const [isSubmitting, setIsSubmitting] = useState(false); - - const resetForm = () => { - setIndexes([]); - }; - - const handleClose = () => { - if (!isSubmitting) { - resetForm(); - onOpenChange(false); - } - }; - - const addIndex = () => { - const newIndex: CreateIndexDefinition = { - table_name: tableName, - index_name: "", - column_name: "", - is_unique: false, - is_primary: false, - index_type: "BTREE", - }; - setIndexes([...indexes, newIndex]); - }; - - const removeIndex = (index: number) => { - setIndexes(indexes.filter((_, i) => i !== index)); - }; - - const updateIndex = (index: number, field: keyof CreateIndexDefinition, value: any) => { - const updated = indexes.map((idx, i) => { - if (i === index) { - return { ...idx, [field]: value }; - } - return idx; + const { + handleClose, + handleSkip, + handleSubmit, + addIndex, + removeIndex, + updateIndex, + indexes, + isSubmitting } + = useAddIndexDialog({ + onOpenChange, + dbId, + schemaName, + tableName, + onSuccess }); - setIndexes(updated); - }; - - const validateForm = (): boolean => { - if (indexes.length === 0) { - toast.error("At least one index is required"); - return false; - } - - // Validate indexes - for (let i = 0; i < indexes.length; i++) { - const idx = indexes[i]; - if (!idx.index_name.trim()) { - toast.error(`Index ${i + 1}: Index name is required`); - return false; - } - if (!idx.column_name.trim()) { - toast.error(`Index ${i + 1}: Column is required`); - return false; - } - } - - return true; - }; - - const handleSubmit = async () => { - if (!validateForm()) { - return; - } - - setIsSubmitting(true); - - try { - // Prepare indexes with updated table name - const preparedIndexes = indexes.map((idx) => ({ - ...idx, - table_name: tableName.trim(), - })); - - const res = await bridgeApi.createIndexes({ - dbId, - schemaName, - indexes: preparedIndexes, - }); - - toast.success("Indexes created successfully", { - description: `${indexes.length} index(es) created for table "${tableName}".`, - }); - - resetForm(); - onOpenChange(false); - - if (onSuccess) { - onSuccess(); - } - } catch (error: any) { - console.error("Failed to create indexes:", error); - toast.error("Failed to create indexes", { - description: error.message || "An unknown error occurred", - }); - } finally { - setIsSubmitting(false); - } - }; - - const handleSkip = () => { - resetForm(); - onOpenChange(false); - if (onSuccess) { - onSuccess(); - } - }; return ( @@ -186,7 +90,7 @@ export default function AddIndexesDialog({ ) : ( - + {indexes.map((idx, index) => ( ([]); - const [isSubmitting, setIsSubmitting] = useState(false); - - const resetForm = () => { - setOperations([]); - }; - - const handleClose = () => { - if (!isSubmitting) { - resetForm(); - onOpenChange(false); - } - }; - - const addOperation = () => { - // Default to ADD_COLUMN - const newOp: AlterTableOperation = { - type: "ADD_COLUMN", - column: { - name: "", - type: "TEXT", - not_nullable: false, - is_primary_key: false, - default_value: "", - }, - }; - setOperations([...operations, newOp]); - }; - - const removeOperation = (index: number) => { - setOperations(operations.filter((_, i) => i !== index)); - }; - - const updateOperation = (index: number, newOp: AlterTableOperation) => { - const updated = [...operations]; - updated[index] = newOp; - setOperations(updated); - }; - - const handleSubmit = async () => { - if (operations.length === 0) { - toast.error("At least one operation is required"); - return; - } - - // Validate operations - for (let i = 0; i < operations.length; i++) { - const op = operations[i]; - - if (op.type === "ADD_COLUMN") { - if (!op.column.name.trim()) { - toast.error(`Operation ${i + 1}: Column name is required`); - return; - } - } else if (op.type === "DROP_COLUMN" || op.type === "SET_NOT_NULL" || - op.type === "DROP_NOT_NULL" || op.type === "DROP_DEFAULT" || - op.type === "ALTER_TYPE") { - if (!op.column_name?.trim()) { - toast.error(`Operation ${i + 1}: Column name is required`); - return; - } - } else if (op.type === "RENAME_COLUMN") { - if (!op.from?.trim() || !op.to?.trim()) { - toast.error(`Operation ${i + 1}: Both column names are required`); - return; - } - } else if (op.type === "SET_DEFAULT") { - if (!op.column_name?.trim() || !op.default_value?.trim()) { - toast.error(`Operation ${i + 1}: Column name and default value are required`); - return; - } - } - } - - setIsSubmitting(true); - - try { - // Generate migration instead of altering table directly - const result = await bridgeApi.generateAlterMigration({ - dbId, - schemaName, - tableName, - operations, - }); - - // Auto-apply the migration immediately - await bridgeApi.applyMigration(dbId, result.version); - - toast.success("Table altered successfully!", { - description: `Migration ${result.filename} was generated and applied. You can rollback from the Migrations panel if needed.`, - }); - onOpenChange(false); - - if (onSuccess) { - onSuccess(); - } - } catch (error: any) { - console.error("Failed to create migration:", error); - toast.error("Failed to create migration", { - description: error.message || "An unknown error occurred", - }); - } finally { - setIsSubmitting(false); - } - }; - + const { + handleClose, + handleSubmit, + addOperation, + removeOperation, + updateOperation, + operations, + isSubmitting + } = useAlterTableDialog({ + onOpenChange, + dbId, + schemaName, + tableName, + onSuccess, + open, + availableColumns + }) return ( @@ -210,7 +123,7 @@ export default function AlterTableDialog({ ) : ( - + {operations.map((op, index) => ( void; + dbId: string; + schemaName: string; + onSuccess?: () => void; +} + +export default function CreateTableDialog({ + open, + onOpenChange, + dbId, + schemaName, + onSuccess, +}: CreateTableDialogProps) { + const { + handleClose, + handleIndexesSuccess, + handleSubmit, + tableName, + setColumns, + setTableName, + columns, foreignKeys, + setForeignKeys, + availableTables, + isSubmitting, + showIndexesDialog, + setShowIndexesDialog, + setCreatedTableName, + createdTableName } = useCreateTableDialog({ + onOpenChange, + dbId, + schemaName, + onSuccess, + open + }); + + return ( + <> + + + + + + Create New Table + + + Create a new table in schema {schemaName} + + + + + + + + Cancel + + + {isSubmitting ? ( + <> + + Creating... + > + ) : ( + "Create Table" + )} + + + + + + {/* Indexes Dialog - shown after table creation */} + col.name).filter(Boolean)} + onSuccess={handleIndexesSuccess} + /> + > + ); +} diff --git a/src/components/schema-explorer/DropTableDialog.tsx b/src/features/schema-explorer/components/DropTableDialog.tsx similarity index 75% rename from src/components/schema-explorer/DropTableDialog.tsx rename to src/features/schema-explorer/components/DropTableDialog.tsx index 44b419d..7d4e1a2 100644 --- a/src/components/schema-explorer/DropTableDialog.tsx +++ b/src/features/schema-explorer/components/DropTableDialog.tsx @@ -1,5 +1,3 @@ -import { useState } from "react"; -import { toast } from "sonner"; import { Dialog, DialogContent, @@ -19,8 +17,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Loader2, AlertTriangle } from "lucide-react"; -import { DropMode } from "@/types/database"; -import { bridgeApi } from "@/services/bridgeApi"; +import { DropMode } from "@/features/database/types"; +import useDropTableDialog from "../hooks/useDropTableDialog"; interface DropTableDialogProps { open: boolean; @@ -39,64 +37,22 @@ export default function DropTableDialog({ tableName, onSuccess, }: DropTableDialogProps) { - const [mode, setMode] = useState("RESTRICT"); - const [confirmText, setConfirmText] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - - const resetForm = () => { - setMode("RESTRICT"); - setConfirmText(""); - }; - - const handleClose = () => { - if (!isSubmitting) { - resetForm(); - onOpenChange(false); - } - }; - - const handleSubmit = async () => { - // Validate confirmation - if (confirmText !== tableName) { - toast.error("Table name doesn't match", { - description: `Please type "${tableName}" to confirm deletion.`, - }); - return; - } - - setIsSubmitting(true); - - try { - // Generate migration instead of dropping table directly - const result = await bridgeApi.generateDropMigration({ - dbId, - schemaName, - tableName, - mode, - }); - - // Auto-apply the migration immediately - await bridgeApi.applyMigration(dbId, result.version); - - toast.success("Table dropped successfully!", { - description: `Migration ${result.filename} was generated and applied. You can rollback from the Migrations panel if needed.`, - }); - - resetForm(); - onOpenChange(false); - - if (onSuccess) { - onSuccess(); - } - } catch (error: any) { - console.error("Failed to drop table:", error); - toast.error("Failed to drop table", { - description: error.message || "An unknown error occurred", - }); - } finally { - setIsSubmitting(false); - } - }; + const { + handleClose, + handleSubmit, + setConfirmText, + setMode, + mode, + confirmText, + isSubmitting + } = useDropTableDialog({ + open, + onOpenChange, + dbId, + schemaName, + tableName, + onSuccess, + }) return ( diff --git a/src/components/schema-explorer/ForeignKeyRow.tsx b/src/features/schema-explorer/components/ForeignKeyRow.tsx similarity index 99% rename from src/components/schema-explorer/ForeignKeyRow.tsx rename to src/features/schema-explorer/components/ForeignKeyRow.tsx index b67b39a..55775ff 100644 --- a/src/components/schema-explorer/ForeignKeyRow.tsx +++ b/src/features/schema-explorer/components/ForeignKeyRow.tsx @@ -3,7 +3,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Trash2 } from "lucide-react"; -import { ForeignKeyConstraint } from "@/types/database"; +import { ForeignKeyConstraint } from "@/features/database/types"; const REFERENTIAL_ACTIONS = [ { value: "CASCADE", label: "CASCADE" }, diff --git a/src/components/schema-explorer/IndexRow.tsx b/src/features/schema-explorer/components/IndexRow.tsx similarity index 98% rename from src/components/schema-explorer/IndexRow.tsx rename to src/features/schema-explorer/components/IndexRow.tsx index abb18e3..3608926 100644 --- a/src/components/schema-explorer/IndexRow.tsx +++ b/src/features/schema-explorer/components/IndexRow.tsx @@ -4,7 +4,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; import { Trash2 } from "lucide-react"; -import { CreateIndexDefinition } from "@/types/database"; +import { CreateIndexDefinition } from "@/features/database/types"; const INDEX_TYPES = [ { value: "BTREE", label: "BTREE" }, diff --git a/src/components/schema-explorer/MetaDataPanel.tsx b/src/features/schema-explorer/components/MetaDataPanel.tsx similarity index 98% rename from src/components/schema-explorer/MetaDataPanel.tsx rename to src/features/schema-explorer/components/MetaDataPanel.tsx index b49cc65..7e3895b 100644 --- a/src/components/schema-explorer/MetaDataPanel.tsx +++ b/src/features/schema-explorer/components/MetaDataPanel.tsx @@ -1,5 +1,5 @@ import { ScrollArea } from "@/components/ui/scroll-area"; -import { DatabaseSchema } from "./types"; +import { DatabaseSchema } from "../types"; import { DatabaseDetails, SchemaDetails, diff --git a/src/components/schema-explorer/SchemaExplorerHeader.tsx b/src/features/schema-explorer/components/SchemaExplorerHeader.tsx similarity index 100% rename from src/components/schema-explorer/SchemaExplorerHeader.tsx rename to src/features/schema-explorer/components/SchemaExplorerHeader.tsx diff --git a/src/components/schema-explorer/SchemaExplorerPanel.tsx b/src/features/schema-explorer/components/SchemaExplorerPanel.tsx similarity index 64% rename from src/components/schema-explorer/SchemaExplorerPanel.tsx rename to src/features/schema-explorer/components/SchemaExplorerPanel.tsx index 3c95a20..360bd82 100644 --- a/src/components/schema-explorer/SchemaExplorerPanel.tsx +++ b/src/features/schema-explorer/components/SchemaExplorerPanel.tsx @@ -1,14 +1,12 @@ -import { useState, useEffect } from "react"; -import { toast } from "sonner"; import { AlertCircle, RefreshCw, Download, Wifi, WifiOff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; -import { useInvalidateCache } from "@/hooks/useDbQueries"; -import { useSchemaExplorerData } from "@/hooks/useSchemaExplorerData"; -import { ColumnDetails, DatabaseSchemaDetails, SchemaGroup, TableSchemaDetails } from "@/types/database"; -import TreeViewPanel from "@/components/schema-explorer/TreeViewPanel"; -import SchemaExplorerHeader from "@/components/schema-explorer/SchemaExplorerHeader"; -import MetaDataPanel from "@/components/schema-explorer/MetaDataPanel"; +import { ColumnDetails, DatabaseSchemaDetails, SchemaGroup, TableSchemaDetails } from "@/features/database/types"; +import SchemaExplorerHeader from "./SchemaExplorerHeader"; +import MetaDataPanel from "./MetaDataPanel"; +import { useSchemaExplorerPanel } from "../hooks/useSchemaExplorerPanel"; +import { TreeViewPanel } from "@/features/tree"; + interface Column extends ColumnDetails { foreignKeyRef?: string; @@ -34,102 +32,24 @@ interface SchemaExplorerPanelProps { export default function SchemaExplorerPanel({ dbId, projectId }: SchemaExplorerPanelProps) { // Offline-first data source: prefers live DB, falls back to project files const { - schemaData, isLoading, - dataSource, - hasLiveSchema, - syncFromDatabase, + schemaData, refetch, - } = useSchemaExplorerData(dbId, projectId); - - const [isSyncing, setIsSyncing] = useState(false); - const { invalidateDatabase } = useInvalidateCache(); - const error = dataSource === "none" && !isLoading ? "No schema data available" : null; - - const [expandedSchemas, setExpandedSchemas] = useState>(new Set()); - const [expandedTables, setExpandedTables] = useState>(new Set()); - const [selectedItem, setSelectedItem] = useState(null); - const [selectedTable, setSelectedTable] = useState<{ schema: string; name: string; columns: string[] } | null>(null); - - // Initialize expanded schemas and selected item when data loads - useEffect(() => { - if (schemaData) { - setSelectedItem(schemaData.name); - if (schemaData.schemas && schemaData.schemas.length > 0) { - setExpandedSchemas(new Set([schemaData.schemas[0].name])); - } - } - }, [schemaData]); - - // Update selectedTable when selectedItem changes - useEffect(() => { - if (!selectedItem || !schemaData) { - setSelectedTable(null); - return; - } - - // Parse selectedItem format: "database.schema.table" - const parts = selectedItem.split(':::'); - - // If it's just database or database.schema, no table selected - if (parts.length < 3) { - setSelectedTable(null); - return; - } - - const schemaName = parts[1]; - const tableName = parts[2]; - - // Find the table in schemaData - const schema = schemaData.schemas?.find((s: any) => s.name === schemaName); - if (!schema) { - setSelectedTable(null); - return; - } - - const table = schema.tables?.find((t: any) => t.name === tableName); - if (!table) { - setSelectedTable(null); - return; - } - - // Extract column names - const columns = table.columns?.map((c: any) => c.name) || []; - - setSelectedTable({ - schema: schemaName, - name: tableName, - columns: columns - }); - }, [selectedItem, schemaData]); - - // --- Toggle helpers --- - const toggleSchema = (schemaName: string) => { - const newExpanded = new Set(expandedSchemas); - newExpanded.has(schemaName) ? newExpanded.delete(schemaName) : newExpanded.add(schemaName); - setExpandedSchemas(newExpanded); - }; - - const toggleTable = (tableName: string) => { - const newExpanded = new Set(expandedTables); - newExpanded.has(tableName) ? newExpanded.delete(tableName) : newExpanded.add(tableName); - setExpandedTables(newExpanded); - }; - - // --- Action handlers --- - const handlePreviewRows = (tableName: string) => toast.success(`Showing preview for ${tableName}`); - const handleShowDDL = (tableName: string) => toast.success(`Generated DDL for ${tableName}`); - const handleCopy = (text: string, type: string) => { - const el = document.createElement("textarea"); - el.value = text; - document.body.appendChild(el); - el.select(); - document.execCommand("copy"); - document.body.removeChild(el); - toast.success(`${type} copied to clipboard`); - }; - const handleExport = (tableName: string) => toast.success(`Exported ${tableName} successfully`); - + error, + expandedSchemas, + expandedTables, + invalidateDatabase, + isSyncing, + syncFromDatabase, + selectedItem, + setSelectedItem, + setIsSyncing, + hasLiveSchema, + toggleSchema, + toggleTable, + dataSource, + selectedTable + } = useSchemaExplorerPanel({ dbId, projectId }); // --- Conditional rendering --- if (isLoading) { return ( diff --git a/src/components/schema-explorer/TableDesignerForm.tsx b/src/features/schema-explorer/components/TableDesignerForm.tsx similarity index 85% rename from src/components/schema-explorer/TableDesignerForm.tsx rename to src/features/schema-explorer/components/TableDesignerForm.tsx index 4f25f6f..89f6fa6 100644 --- a/src/components/schema-explorer/TableDesignerForm.tsx +++ b/src/features/schema-explorer/components/TableDesignerForm.tsx @@ -4,8 +4,9 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; import { Trash2, Plus, Link2 } from "lucide-react"; -import { CreateTableColumn, ForeignKeyConstraint } from "@/types/database"; +import { CreateTableColumn, ForeignKeyConstraint } from "@/features/database/types"; import ForeignKeyRow from "./ForeignKeyRow"; +import useTableDesignerForm from "../hooks/useTableDesignerForm"; const DATA_TYPES = [ { value: "INT", label: "Integer" }, @@ -37,60 +38,23 @@ export default function TableDesignerForm({ currentSchema, availableTables, }: TableDesignerFormProps) { - const addColumn = () => { - const newColumn: CreateTableColumn = { - name: "", - type: "TEXT", - not_nullable: false, - is_primary_key: false, - default_value: "", - }; - onColumnsChange([...columns, newColumn]); - }; - - const removeColumn = (index: number) => { - onColumnsChange(columns.filter((_, i) => i !== index)); - }; - - const updateColumn = (index: number, field: keyof CreateTableColumn, value: any) => { - const updated = columns.map((col, i) => { - if (i === index) { - return { ...col, [field]: value }; - } - return col; - }); - onColumnsChange(updated); - }; - - const addForeignKey = () => { - const newForeignKey: ForeignKeyConstraint = { - constraint_name: "", - source_schema: currentSchema, - source_table: tableName, - source_column: "", - target_schema: currentSchema, - target_table: "", - target_column: "", - update_rule: "NO ACTION", - delete_rule: "NO ACTION", - }; - onForeignKeysChange([...foreignKeys, newForeignKey]); - }; - - const removeForeignKey = (index: number) => { - onForeignKeysChange(foreignKeys.filter((_, i) => i !== index)); - }; - - const updateForeignKey = (index: number, field: keyof ForeignKeyConstraint, value: string) => { - const updated = foreignKeys.map((fk, i) => { - if (i === index) { - return { ...fk, [field]: value }; - } - return fk; - }); - onForeignKeysChange(updated); - }; - + const { + addColumn, + addForeignKey, + removeColumn, + removeForeignKey, + updateColumn, + updateForeignKey + } = useTableDesignerForm({ + onForeignKeysChange, + onTableNameChange, + onColumnsChange, + tableName, + columns, + foreignKeys, + currentSchema, + availableTables + }) return ( {/* Table Name */} @@ -130,7 +94,7 @@ export default function TableDesignerForm({ ) : ( - + {columns.map((column, index) => ( ) : ( - + {foreignKeys.map((fk, index) => ( - Column + Column Type Nullable Default diff --git a/src/components/schema-explorer/metadata/index.ts b/src/features/schema-explorer/components/metadata/index.ts similarity index 100% rename from src/components/schema-explorer/metadata/index.ts rename to src/features/schema-explorer/components/metadata/index.ts diff --git a/src/features/schema-explorer/hooks/useAddIndexDialog.ts b/src/features/schema-explorer/hooks/useAddIndexDialog.ts new file mode 100644 index 0000000..0b37085 --- /dev/null +++ b/src/features/schema-explorer/hooks/useAddIndexDialog.ts @@ -0,0 +1,138 @@ +import { CreateIndexDefinition } from "@/features/database/types"; +import { databaseService } from "@/services/bridge/database"; +import { useState } from "react"; +import { toast } from "sonner"; + + +interface AddIndexesDialogProps { + onOpenChange: (open: boolean) => void; + dbId: string; + schemaName: string; + tableName: string; + onSuccess?: () => void; +} + + +export function useAddIndexDialog({ onOpenChange, dbId, schemaName, tableName, onSuccess }: AddIndexesDialogProps) { + const [indexes, setIndexes] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + + const resetForm = () => { + setIndexes([]); + }; + + const handleClose = () => { + if (!isSubmitting) { + resetForm(); + onOpenChange(false); + } + }; + + const addIndex = () => { + const newIndex: CreateIndexDefinition = { + table_name: tableName, + index_name: "", + column_name: "", + is_unique: false, + is_primary: false, + index_type: "BTREE", + }; + setIndexes([...indexes, newIndex]); + }; + + const removeIndex = (index: number) => { + setIndexes(indexes.filter((_, i) => i !== index)); + }; + + const updateIndex = (index: number, field: keyof CreateIndexDefinition, value: any) => { + const updated = indexes.map((idx, i) => { + if (i === index) { + return { ...idx, [field]: value }; + } + return idx; + }); + setIndexes(updated); + }; + + const validateForm = (): boolean => { + if (indexes.length === 0) { + toast.error("At least one index is required"); + return false; + } + + // Validate indexes + for (let i = 0; i < indexes.length; i++) { + const idx = indexes[i]; + if (!idx.index_name.trim()) { + toast.error(`Index ${i + 1}: Index name is required`); + return false; + } + if (!idx.column_name.trim()) { + toast.error(`Index ${i + 1}: Column is required`); + return false; + } + } + + return true; + }; + + const handleSubmit = async () => { + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + + try { + // Prepare indexes with updated table name + const preparedIndexes = indexes.map((idx) => ({ + ...idx, + table_name: tableName.trim(), + })); + + const res = await databaseService.createIndexes({ + dbId, + schemaName, + indexes: preparedIndexes, + }); + + toast.success("Indexes created successfully", { + description: `${indexes.length} index(es) created for table "${tableName}".`, + }); + + resetForm(); + onOpenChange(false); + + if (onSuccess) { + onSuccess(); + } + } catch (error: any) { + console.error("Failed to create indexes:", error); + toast.error("Failed to create indexes", { + description: error.message || "An unknown error occurred", + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleSkip = () => { + resetForm(); + onOpenChange(false); + if (onSuccess) { + onSuccess(); + } + }; + + return { + indexes, + isSubmitting, + addIndex, + removeIndex, + updateIndex, + handleSubmit, + handleClose, + handleSkip, + }; + +} \ No newline at end of file diff --git a/src/features/schema-explorer/hooks/useAlterTableDialog.ts b/src/features/schema-explorer/hooks/useAlterTableDialog.ts new file mode 100644 index 0000000..c8254d6 --- /dev/null +++ b/src/features/schema-explorer/hooks/useAlterTableDialog.ts @@ -0,0 +1,137 @@ +import { AlterTableOperation } from "@/features/database/types"; +import { migrationService } from "@/services/bridge/migration"; +import { useState } from "react"; +import { toast } from "sonner"; + + + +interface AlterTableDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + dbId: string; + schemaName: string; + tableName: string; + availableColumns: string[]; + onSuccess?: () => void; +} + + + +export function useAlterTableDialog({ onOpenChange, dbId, schemaName, tableName, onSuccess }: AlterTableDialogProps) { + const [operations, setOperations] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + + const resetForm = () => { + setOperations([]); + }; + + const handleClose = () => { + if (!isSubmitting) { + resetForm(); + onOpenChange(false); + } + }; + + const addOperation = () => { + // Default to ADD_COLUMN + const newOp: AlterTableOperation = { + type: "ADD_COLUMN", + column: { + name: "", + type: "TEXT", + not_nullable: false, + is_primary_key: false, + default_value: "", + }, + }; + setOperations([...operations, newOp]); + }; + + const removeOperation = (index: number) => { + setOperations(operations.filter((_, i) => i !== index)); + }; + + const updateOperation = (index: number, newOp: AlterTableOperation) => { + const updated = [...operations]; + updated[index] = newOp; + setOperations(updated); + }; + + const handleSubmit = async () => { + if (operations.length === 0) { + toast.error("At least one operation is required"); + return; + } + + // Validate operations + for (let i = 0; i < operations.length; i++) { + const op = operations[i]; + + if (op.type === "ADD_COLUMN") { + if (!op.column.name.trim()) { + toast.error(`Operation ${i + 1}: Column name is required`); + return; + } + } else if (op.type === "DROP_COLUMN" || op.type === "SET_NOT_NULL" || + op.type === "DROP_NOT_NULL" || op.type === "DROP_DEFAULT" || + op.type === "ALTER_TYPE") { + if (!op.column_name?.trim()) { + toast.error(`Operation ${i + 1}: Column name is required`); + return; + } + } else if (op.type === "RENAME_COLUMN") { + if (!op.from?.trim() || !op.to?.trim()) { + toast.error(`Operation ${i + 1}: Both column names are required`); + return; + } + } else if (op.type === "SET_DEFAULT") { + if (!op.column_name?.trim() || !op.default_value?.trim()) { + toast.error(`Operation ${i + 1}: Column name and default value are required`); + return; + } + } + } + + setIsSubmitting(true); + + try { + // Generate migration instead of altering table directly + const result = await migrationService.generateAlterMigration({ + dbId, + schemaName, + tableName, + operations, + }); + + // Auto-apply the migration immediately + await migrationService.applyMigration(dbId, result.version); + + toast.success("Table altered successfully!", { + description: `Migration ${result.filename} was generated and applied. You can rollback from the Migrations panel if needed.`, + }); + onOpenChange(false); + + if (onSuccess) { + onSuccess(); + } + } catch (error: any) { + console.error("Failed to create migration:", error); + toast.error("Failed to create migration", { + description: error.message || "An unknown error occurred", + }); + } finally { + setIsSubmitting(false); + } + }; + + return { + operations, + addOperation, + removeOperation, + updateOperation, + handleSubmit, + handleClose, + isSubmitting, + } + +} diff --git a/src/components/schema-explorer/CreateTableDialog.tsx b/src/features/schema-explorer/hooks/useCreateTableDialog.ts similarity index 60% rename from src/components/schema-explorer/CreateTableDialog.tsx rename to src/features/schema-explorer/hooks/useCreateTableDialog.ts index 641525f..a8a4172 100644 --- a/src/components/schema-explorer/CreateTableDialog.tsx +++ b/src/features/schema-explorer/hooks/useCreateTableDialog.ts @@ -1,19 +1,8 @@ -import { useState, useEffect } from "react"; +import { CreateTableColumn, ForeignKeyConstraint } from "@/features/database/types"; +import { databaseService } from "@/services/bridge/database"; +import { migrationService } from "@/services/bridge/migration"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Loader2, Table } from "lucide-react"; -import TableDesignerForm from "./TableDesignerForm"; -import AddIndexesDialog from "./AddIndexesDialog"; -import { CreateTableColumn, ForeignKeyConstraint } from "@/types/database"; -import { bridgeApi } from "@/services/bridgeApi"; interface CreateTableDialogProps { open: boolean; @@ -23,13 +12,8 @@ interface CreateTableDialogProps { onSuccess?: () => void; } -export default function CreateTableDialog({ - open, - onOpenChange, - dbId, - schemaName, - onSuccess, -}: CreateTableDialogProps) { + +export function useCreateTableDialog({ onOpenChange, dbId, schemaName, onSuccess, open }: CreateTableDialogProps) { const [tableName, setTableName] = useState(""); const [columns, setColumns] = useState([]); const [foreignKeys, setForeignKeys] = useState([]); @@ -49,7 +33,7 @@ export default function CreateTableDialog({ const fetchAvailableTables = async () => { try { - const schema = await bridgeApi.getSchema(dbId); + const schema = await databaseService.getSchema(dbId); const tables: Array<{ schema: string; name: string }> = []; schema?.schemas?.forEach((schemaGroup: any) => { @@ -146,7 +130,7 @@ export default function CreateTableDialog({ })); // Generate migration instead of creating table directly - const result = await bridgeApi.generateCreateMigration({ + const result = await migrationService.generateCreateMigration({ dbId, schemaName, tableName: tableName.trim(), @@ -159,7 +143,7 @@ export default function CreateTableDialog({ }); // Auto-apply the migration immediately - await bridgeApi.applyMigration(dbId, result.version); + await migrationService.applyMigration(dbId, result.version); toast.success("Table created successfully!", { description: `Migration ${result.filename} was generated and applied. You can rollback from the Migrations panel if needed.`, @@ -193,68 +177,21 @@ export default function CreateTableDialog({ } }; - return ( - <> - - - - - - Create New Table - - - Create a new table in schema {schemaName} - - - - - - - - Cancel - - - {isSubmitting ? ( - <> - - Creating... - > - ) : ( - "Create Table" - )} - - - - - - {/* Indexes Dialog - shown after table creation */} - col.name).filter(Boolean)} - onSuccess={handleIndexesSuccess} - /> - > - ); -} + return { + tableName, + setTableName, + columns, + setColumns, + foreignKeys, + setForeignKeys, + availableTables, + isSubmitting, + handleSubmit, + handleClose, + showIndexesDialog, + setShowIndexesDialog, + createdTableName, + setCreatedTableName, + handleIndexesSuccess + } +} \ No newline at end of file diff --git a/src/features/schema-explorer/hooks/useDropTableDialog.ts b/src/features/schema-explorer/hooks/useDropTableDialog.ts new file mode 100644 index 0000000..09f49b5 --- /dev/null +++ b/src/features/schema-explorer/hooks/useDropTableDialog.ts @@ -0,0 +1,85 @@ +import { DropMode } from "@/features/database/types"; +import { migrationService } from "@/services/bridge/migration"; +import { useState } from "react"; +import { toast } from "sonner"; + +interface DropTableDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + dbId: string; + schemaName: string; + tableName: string; + onSuccess?: () => void; +} + + +export default function useDropTableDialog({ onOpenChange, dbId, schemaName, tableName, onSuccess }: DropTableDialogProps) { + const [mode, setMode] = useState("RESTRICT"); + const [confirmText, setConfirmText] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const resetForm = () => { + setMode("RESTRICT"); + setConfirmText(""); + }; + + const handleClose = () => { + if (!isSubmitting) { + resetForm(); + onOpenChange(false); + } + }; + + const handleSubmit = async () => { + // Validate confirmation + if (confirmText !== tableName) { + toast.error("Table name doesn't match", { + description: `Please type "${tableName}" to confirm deletion.`, + }); + return; + } + + setIsSubmitting(true); + + try { + // Generate migration instead of dropping table directly + const result = await migrationService.generateDropMigration({ + dbId, + schemaName, + tableName, + mode, + }); + + // Auto-apply the migration immediately + await migrationService.applyMigration(dbId, result.version); + + toast.success("Table dropped successfully!", { + description: `Migration ${result.filename} was generated and applied. You can rollback from the Migrations panel if needed.`, + }); + + resetForm(); + onOpenChange(false); + + if (onSuccess) { + onSuccess(); + } + } catch (error: any) { + console.error("Failed to drop table:", error); + toast.error("Failed to drop table", { + description: error.message || "An unknown error occurred", + }); + } finally { + setIsSubmitting(false); + } + }; + + return { + mode, + setMode, + confirmText, + setConfirmText, + isSubmitting, + handleClose, + handleSubmit, + } +} \ No newline at end of file diff --git a/src/hooks/useSchemaExplorerData.ts b/src/features/schema-explorer/hooks/useSchemaExplorerData.ts similarity index 91% rename from src/hooks/useSchemaExplorerData.ts rename to src/features/schema-explorer/hooks/useSchemaExplorerData.ts index 618800a..016a9f5 100644 --- a/src/hooks/useSchemaExplorerData.ts +++ b/src/features/schema-explorer/hooks/useSchemaExplorerData.ts @@ -1,14 +1,15 @@ import { useMemo, useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { useFullSchema } from "@/hooks/useDbQueries"; +import { useFullSchema } from "@/features/project/hooks/useDbQueries"; import { useProjectSchema, projectKeys, -} from "@/hooks/useProjectQueries"; -import { bridgeApi } from "@/services/bridgeApi"; +} from "@/features/project/hooks/useProjectQueries"; import { snapshotToSchemaDetails, schemaGroupsToSnapshots } from "@/lib/schemaConverters"; -import type { DatabaseSchemaDetails } from "@/types/database"; +import type { DatabaseSchemaDetails } from "@/features/database/types"; +import { projectService } from "@/services/bridge/project"; +import { databaseService } from "@/services/bridge/database"; // ================================================================ // useSchemaExplorerData @@ -117,7 +118,7 @@ export function useSchemaExplorerData( try { // 1. Fetch fresh schema from live database - const freshSchema = await bridgeApi.getSchema(dbId); + const freshSchema = await databaseService.getSchema(dbId); if (!freshSchema?.schemas?.length) { toast.warning("Database returned no schemas"); @@ -126,7 +127,7 @@ export function useSchemaExplorerData( // 2. Convert to snapshots and save to project schema.json const snapshots = schemaGroupsToSnapshots(freshSchema.schemas); - await bridgeApi.saveProjectSchema(projectId, snapshots); + await projectService.saveProjectSchema(projectId, snapshots); // 3. Invalidate React Query caches to trigger re-render queryClient.invalidateQueries({ diff --git a/src/features/schema-explorer/hooks/useSchemaExplorerPanel.ts b/src/features/schema-explorer/hooks/useSchemaExplorerPanel.ts new file mode 100644 index 0000000..56059d7 --- /dev/null +++ b/src/features/schema-explorer/hooks/useSchemaExplorerPanel.ts @@ -0,0 +1,124 @@ +import { toast } from "sonner"; +import { useSchemaExplorerData } from "./useSchemaExplorerData"; +import { useEffect, useState } from "react"; +import { useInvalidateCache } from "@/features/project/hooks/useDbQueries"; + + +export function useSchemaExplorerPanel({ dbId, projectId }: { dbId: string; projectId?: string | null }) { + const { + schemaData, + isLoading, + dataSource, + hasLiveSchema, + syncFromDatabase, + refetch, + } = useSchemaExplorerData(dbId, projectId); + + const [isSyncing, setIsSyncing] = useState(false); + const { invalidateDatabase } = useInvalidateCache(); + const error = dataSource === "none" && !isLoading ? "No schema data available" : null; + + const [expandedSchemas, setExpandedSchemas] = useState>(new Set()); + const [expandedTables, setExpandedTables] = useState>(new Set()); + const [selectedItem, setSelectedItem] = useState(null); + const [selectedTable, setSelectedTable] = useState<{ schema: string; name: string; columns: string[] } | null>(null); + + // Initialize expanded schemas and selected item when data loads + useEffect(() => { + if (schemaData) { + setSelectedItem(schemaData.name); + if (schemaData.schemas && schemaData.schemas.length > 0) { + setExpandedSchemas(new Set([schemaData.schemas[0].name])); + } + } + }, [schemaData]); + + // Update selectedTable when selectedItem changes + useEffect(() => { + if (!selectedItem || !schemaData) { + setSelectedTable(null); + return; + } + + // Parse selectedItem format: "database.schema.table" + const parts = selectedItem.split(':::'); + + // If it's just database or database.schema, no table selected + if (parts.length < 3) { + setSelectedTable(null); + return; + } + + const schemaName = parts[1]; + const tableName = parts[2]; + + // Find the table in schemaData + const schema = schemaData.schemas?.find((s: any) => s.name === schemaName); + if (!schema) { + setSelectedTable(null); + return; + } + + const table = schema.tables?.find((t: any) => t.name === tableName); + if (!table) { + setSelectedTable(null); + return; + } + + // Extract column names + const columns = table.columns?.map((c: any) => c.name) || []; + + setSelectedTable({ + schema: schemaName, + name: tableName, + columns: columns + }); + }, [selectedItem, schemaData]); + + // --- Toggle helpers --- + const toggleSchema = (schemaName: string) => { + const newExpanded = new Set(expandedSchemas); + newExpanded.has(schemaName) ? newExpanded.delete(schemaName) : newExpanded.add(schemaName); + setExpandedSchemas(newExpanded); + }; + + const toggleTable = (tableName: string) => { + const newExpanded = new Set(expandedTables); + newExpanded.has(tableName) ? newExpanded.delete(tableName) : newExpanded.add(tableName); + setExpandedTables(newExpanded); + }; + + // --- Action handlers --- + const handlePreviewRows = (tableName: string) => toast.success(`Showing preview for ${tableName}`); + const handleShowDDL = (tableName: string) => toast.success(`Generated DDL for ${tableName}`); + const handleCopy = (text: string, type: string) => { + const el = document.createElement("textarea"); + el.value = text; + document.body.appendChild(el); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); + toast.success(`${type} copied to clipboard`); + }; + const handleExport = (tableName: string) => toast.success(`Exported ${tableName} successfully`); + + return { + isLoading, + schemaData, + refetch, + error, + expandedSchemas, + expandedTables, + invalidateDatabase, + isSyncing, + syncFromDatabase, + selectedItem, + setSelectedItem, + setIsSyncing, + hasLiveSchema, + toggleSchema, + toggleTable, + dataSource, + selectedTable + } +} \ No newline at end of file diff --git a/src/features/schema-explorer/hooks/useTableDesignerForm.ts b/src/features/schema-explorer/hooks/useTableDesignerForm.ts new file mode 100644 index 0000000..e7cedd5 --- /dev/null +++ b/src/features/schema-explorer/hooks/useTableDesignerForm.ts @@ -0,0 +1,83 @@ +import { CreateTableColumn, ForeignKeyConstraint } from "@/features/database/types"; + +interface TableDesignerFormProps { + tableName: string; + onTableNameChange: (name: string) => void; + columns: CreateTableColumn[]; + onColumnsChange: (columns: CreateTableColumn[]) => void; + foreignKeys: ForeignKeyConstraint[]; + onForeignKeysChange: (foreignKeys: ForeignKeyConstraint[]) => void; + currentSchema: string; + availableTables: Array<{ schema: string; name: string }>; +} + +export default function useTableDesignerForm({ tableName, + columns, + onColumnsChange, + foreignKeys, + onForeignKeysChange, + currentSchema, +}: TableDesignerFormProps) { + const addColumn = () => { + const newColumn: CreateTableColumn = { + name: "", + type: "TEXT", + not_nullable: false, + is_primary_key: false, + default_value: "", + }; + onColumnsChange([...columns, newColumn]); + }; + + const removeColumn = (index: number) => { + onColumnsChange(columns.filter((_, i) => i !== index)); + }; + + const updateColumn = (index: number, field: keyof CreateTableColumn, value: any) => { + const updated = columns.map((col, i) => { + if (i === index) { + return { ...col, [field]: value }; + } + return col; + }); + onColumnsChange(updated); + }; + + const addForeignKey = () => { + const newForeignKey: ForeignKeyConstraint = { + constraint_name: "", + source_schema: currentSchema, + source_table: tableName, + source_column: "", + target_schema: currentSchema, + target_table: "", + target_column: "", + update_rule: "NO ACTION", + delete_rule: "NO ACTION", + }; + onForeignKeysChange([...foreignKeys, newForeignKey]); + }; + + const removeForeignKey = (index: number) => { + onForeignKeysChange(foreignKeys.filter((_, i) => i !== index)); + }; + + const updateForeignKey = (index: number, field: keyof ForeignKeyConstraint, value: string) => { + const updated = foreignKeys.map((fk, i) => { + if (i === index) { + return { ...fk, [field]: value }; + } + return fk; + }); + onForeignKeysChange(updated); + }; + + return { + addColumn, + addForeignKey, + removeColumn, + removeForeignKey, + updateColumn, + updateForeignKey + } +} \ No newline at end of file diff --git a/src/components/schema-explorer/types.ts b/src/features/schema-explorer/types.ts similarity index 65% rename from src/components/schema-explorer/types.ts rename to src/features/schema-explorer/types.ts index c59e6f4..b01488d 100644 --- a/src/components/schema-explorer/types.ts +++ b/src/features/schema-explorer/types.ts @@ -4,7 +4,7 @@ import { SchemaGroup, TableSchemaDetails, ForeignKeyInfo, -} from "@/types/database"; +} from "@/features/database/types"; // Extended column with FK reference export interface Column extends BaseColumnDetails { @@ -34,20 +34,7 @@ export const getFkInfo = ( return foreignKeys?.find((fk) => fk.source_column === columnName); }; -// Props interfaces -export interface TreeViewPanelProps { - database: DatabaseSchema; - expandedSchemas: Set; - expandedTables: Set; - toggleSchema: (schemaName: string) => void; - toggleTable: (tableName: string) => void; - selectedItem: string | null; - setSelectedItem: (itemPath: string) => void; - handlePreviewRows: (tableName: string) => void; - handleShowDDL: (tableName: string) => void; - handleCopy: (name: string, type: string) => void; - handleExport: (tableName: string) => void; -} + export interface MetaDataPanelProps { selectedItem: string | null; diff --git a/src/features/settings/components/CheckForUpdates.tsx b/src/features/settings/components/CheckForUpdates.tsx new file mode 100644 index 0000000..9d4e7d2 --- /dev/null +++ b/src/features/settings/components/CheckForUpdates.tsx @@ -0,0 +1,115 @@ +import { Button } from '@/components/ui/button' +import { useUpdater } from '@/features/settings/hooks/useUpdater'; +import { AlertCircle, CheckCircle2, Download, Info, Loader2, RefreshCw } from 'lucide-react' + +export default function CheckForUpdates() { + const { status, updateInfo, downloadProgress, error: updateError, checkForUpdates, downloadAndInstall, relaunchApp } = useUpdater(); + return ( + + + + + + Updates + + Check for new versions of RelWave + + + + + {status === "idle" || status === "up-to-date" || status === "error" || status === "dev-mode" ? ( + + + Check for Updates + + ) : status === "checking" ? ( + + + Checking... + + ) : null} + + + {/* Status messages */} + {status === "up-to-date" && ( + + + + You're running the latest version. + + + )} + + {status === "dev-mode" && ( + + + + Update checks are disabled in development mode. + + + )} + + {status === "error" && updateError && ( + + + + {updateError} + + + )} + + {status === "available" && updateInfo && ( + + + + + v{updateInfo.version} available + {updateInfo.body && ( + {updateInfo.body} + )} + + + + Download & Install + + + + + )} + + {status === "downloading" && ( + + + Downloading update... + {downloadProgress}% + + + + + + )} + + {status === "ready" && ( + + + + + Update downloaded. Restart to apply. + + + Restart Now + + + + )} + + ) +} diff --git a/src/features/settings/components/ColorVariant.tsx b/src/features/settings/components/ColorVariant.tsx new file mode 100644 index 0000000..889e9ed --- /dev/null +++ b/src/features/settings/components/ColorVariant.tsx @@ -0,0 +1,58 @@ +import { useThemeVariant } from '@/features/settings/hooks/useThemeVariant'; +import { themeVariants, ThemeVariant } from "@/lib/themes"; +import { Check, Palette } from 'lucide-react'; + +export default function ColorVariant() { + + const { variant, setVariant } = useThemeVariant(); + return ( + + + + + Accent Color + + Select your preferred color theme + + + + + + {Object.entries(themeVariants).map(([key, config]) => { + const isActive = variant === key; + + return ( + setVariant(key as ThemeVariant)} + className={` + relative p-4 rounded-lg border-2 transition-all + ${isActive + ? "border-primary bg-primary/5" + : "border-border/20 hover:border-border/40 bg-background" + } + `} + > + + + + {config.name} + + + {isActive && ( + + + + + + )} + + ); + })} + + + ) +} \ No newline at end of file diff --git a/src/features/settings/components/DeveloperMode.tsx b/src/features/settings/components/DeveloperMode.tsx new file mode 100644 index 0000000..1401257 --- /dev/null +++ b/src/features/settings/components/DeveloperMode.tsx @@ -0,0 +1,43 @@ +import { Bug, Code2 } from 'lucide-react' +import React from 'react' +import { Switch } from '../../../components/ui/switch' +import { useDeveloperMode } from '@/features/settings/hooks/useDeveloperMode'; + +export default function DeveloperMode() { + const { isEnabled: devModeEnabled, setIsEnabled: setDevModeEnabled } = useDeveloperMode(); + return ( + + + + + + Developer Mode + + Enable developer tools and context menu options + + + + + + + {devModeEnabled && ( + + + + + Developer features enabled: + + Right-click context menu with Inspect, Reload, Back/Forward + Access to browser developer tools (F12) + Keyboard shortcuts for navigation + + + + + )} + + ) +} diff --git a/src/features/settings/components/Header.tsx b/src/features/settings/components/Header.tsx new file mode 100644 index 0000000..aedb874 --- /dev/null +++ b/src/features/settings/components/Header.tsx @@ -0,0 +1,14 @@ +export default function Header() { + return ( + + + + Settings + + Customize your app appearance + + + + + ) +} diff --git a/src/features/settings/components/Preview.tsx b/src/features/settings/components/Preview.tsx new file mode 100644 index 0000000..ff5e8c2 --- /dev/null +++ b/src/features/settings/components/Preview.tsx @@ -0,0 +1,27 @@ +import { Button } from '../../../components/ui/button' + +export default function Preview() { + return ( + + Preview + + + + Primary Button + + + Outline Button + + + Ghost Button + + + + + This is a preview of how text and UI elements will look with your selected theme. + + + + + ) +} diff --git a/src/features/settings/components/ThemeMode.tsx b/src/features/settings/components/ThemeMode.tsx new file mode 100644 index 0000000..2baa197 --- /dev/null +++ b/src/features/settings/components/ThemeMode.tsx @@ -0,0 +1,62 @@ +import { Theme } from '@tauri-apps/api/window'; +import { Check, LucideProps, Monitor, Moon, Sun } from 'lucide-react'; +import { useTheme } from "@/components/providers/ThemeProvider"; + +export default function ThemeMode() { + const { theme, setTheme } = useTheme(); + const themeOptions = [ + { value: "light", label: "Light", icon: Sun }, + { value: "dark", label: "Dark", icon: Moon }, + { value: "system", label: "System", icon: Monitor }, + ]; + + return ( + + + + + Theme Mode + + Choose between light and dark mode + + + + + + {themeOptions.map((option) => { + const Icon = option.icon; + const isActive = theme === option.value; + + return ( + setTheme(option.value as Theme)} + className={` + relative p-4 rounded-lg border-2 transition-all + ${isActive + ? "border-primary bg-primary/5" + : "border-border/20 hover:border-border/40 bg-background" + } + `} + > + + + + {option.label} + + + {isActive && ( + + + + + + )} + + ); + })} + + + ) +} + diff --git a/src/features/settings/components/Version.tsx b/src/features/settings/components/Version.tsx new file mode 100644 index 0000000..be7cedd --- /dev/null +++ b/src/features/settings/components/Version.tsx @@ -0,0 +1,25 @@ +import { getVersion } from '@tauri-apps/api/app'; +import { Info } from 'lucide-react' +import { useEffect, useState } from 'react' + +export default function Version() { + + const [appVersion, setAppVersion] = useState(""); + + useEffect(() => { + getVersion().then(setAppVersion).catch(() => setAppVersion("unknown")); + }, []); + return ( + + + + + About + + RelWave v{appVersion || "—"} + + + + + ) +} diff --git a/src/features/settings/components/index.tsx b/src/features/settings/components/index.tsx new file mode 100644 index 0000000..70ae1b9 --- /dev/null +++ b/src/features/settings/components/index.tsx @@ -0,0 +1,7 @@ +export { default as Header } from './Header' +export { default as ThemeMode } from './ThemeMode' +export { default as ColorVariant } from './ColorVariant' +export {default as DeveloperMode} from './DeveloperMode' +export {default as CheckForUpdates} from './CheckForUpdates' +export {default as Version} from './Version' +export {default as Preview} from './Preview' \ No newline at end of file diff --git a/src/features/settings/hooks/useDevMode.ts b/src/features/settings/hooks/useDevMode.ts new file mode 100644 index 0000000..860f533 --- /dev/null +++ b/src/features/settings/hooks/useDevMode.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; +import { getDeveloperMode } from "./useDeveloperMode"; + +export const useDevMode = () => { + const [devMode, setDevMode] = useState(getDeveloperMode); + + useEffect(() => { + const handleChange = (e: CustomEvent<{ enabled: boolean }>) => { + setDevMode(e.detail.enabled); + }; + + window.addEventListener("developer-mode-change", handleChange as EventListener); + return () => window.removeEventListener("developer-mode-change", handleChange as EventListener); + }, []); + + return devMode; +}; \ No newline at end of file diff --git a/src/features/settings/hooks/useDevModeKeyboard.ts b/src/features/settings/hooks/useDevModeKeyboard.ts new file mode 100644 index 0000000..da1332a --- /dev/null +++ b/src/features/settings/hooks/useDevModeKeyboard.ts @@ -0,0 +1,28 @@ +import { useEffect } from "react"; + +export const useDevModeKeyboard = (devMode: boolean) => { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Always block hard reload inside the app + if ((e.ctrlKey || e.metaKey) && (e.key === "r" || e.key === "R")) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (devMode) return; + + const isDevToolsShortcut = + (e.ctrlKey && e.shiftKey && ["I", "i", "J", "j", "C", "c"].includes(e.key)) || + e.key === "F12"; + + if (isDevToolsShortcut) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [devMode]); +}; \ No newline at end of file diff --git a/src/hooks/useDeveloperMode.ts b/src/features/settings/hooks/useDeveloperMode.ts similarity index 100% rename from src/hooks/useDeveloperMode.ts rename to src/features/settings/hooks/useDeveloperMode.ts diff --git a/src/hooks/useThemeVariant.ts b/src/features/settings/hooks/useThemeVariant.ts similarity index 100% rename from src/hooks/useThemeVariant.ts rename to src/features/settings/hooks/useThemeVariant.ts diff --git a/src/hooks/useUpdater.ts b/src/features/settings/hooks/useUpdater.ts similarity index 100% rename from src/hooks/useUpdater.ts rename to src/features/settings/hooks/useUpdater.ts diff --git a/src/features/settings/hooks/useWebviewActions.ts b/src/features/settings/hooks/useWebviewActions.ts new file mode 100644 index 0000000..48e494c --- /dev/null +++ b/src/features/settings/hooks/useWebviewActions.ts @@ -0,0 +1,50 @@ +import { useCallback } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +export const useWebviewActions = () => { + const reload = useCallback(async () => { + try { + await invoke("reload_webview"); + } catch { + window.location.reload(); + } + }, []); + + const goBack = useCallback(async () => { + try { + await invoke("navigate_back"); + } catch { + window.history.back(); + } + }, []); + + const goForward = useCallback(async () => { + try { + await invoke("navigate_forward"); + } catch { + window.history.forward(); + } + }, []); + + const openDevtools = useCallback(async () => { + try { + await invoke("open_devtools"); + } catch (e) { + console.error("Failed to open devtools:", e); + } + }, []); + + const copy = useCallback(() => document.execCommand("copy"), []); + const cut = useCallback(() => document.execCommand("cut"), []); + + const paste = useCallback(async () => { + try { + const text = await navigator.clipboard.readText(); + document.execCommand("insertText", false, text); + } catch { + document.execCommand("paste"); + } + }, []); + + return { reload, goBack, goForward, openDevtools, copy, cut, paste }; +}; \ No newline at end of file diff --git a/src/features/tree/ColumnTreeItem.tsx b/src/features/tree/ColumnTreeItem.tsx new file mode 100644 index 0000000..af4a076 --- /dev/null +++ b/src/features/tree/ColumnTreeItem.tsx @@ -0,0 +1,92 @@ +// features/schema-explorer/components/tree/ColumnTreeItem.tsx + +import { Copy, Key, Link2 } from "lucide-react"; +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ForeignKeyInfo } from "@/features/database/types"; +import { Column } from "../er-diagram/types"; + +interface ColumnTreeItemProps { + column: Column; + isSelected: boolean; + fkInfo?: ForeignKeyInfo; + isIndexed: boolean; + isUnique: boolean; + onSelect: () => void; + onCopy: (value: string, label: string) => void; +} + +export const ColumnTreeItem = ({ + column, + isSelected, + fkInfo, + isIndexed, + isUnique, + onSelect, + onCopy, +}: ColumnTreeItemProps) => ( + + + + + + + + {column.isPrimaryKey && } + {column.isForeignKey && } + + {column.name} + + {isUnique && ( + + UQ + + )} + {isIndexed && ( + + IDX + + )} + + + {column.type} + {!column.nullable && *} + + + + + + + {column.name} + Type: {column.type} + Nullable: {column.nullable ? "Yes" : "No"} + {column.defaultValue && Default: {column.defaultValue}} + {fkInfo && ( + + → {fkInfo.target_table}.{fkInfo.target_column} + + )} + + + + + + onCopy(column.name, "Column name")} className="hover:bg-accent"> + Copy Column Name + + onCopy(column.type, "Column type")} className="hover:bg-accent"> + Copy Type + + + +); \ No newline at end of file diff --git a/src/features/tree/SchemaExtras.tsx b/src/features/tree/SchemaExtras.tsx new file mode 100644 index 0000000..22fa3b2 --- /dev/null +++ b/src/features/tree/SchemaExtras.tsx @@ -0,0 +1,98 @@ +// features/schema-explorer/components/tree/SchemaExtras.tsx + +import { Hash, List } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { SequenceInfo } from "../database/types"; + +// ---- Enum Types ---- + +interface EnumSectionProps { + dbName: string; + schemaName: string; + enumTypes: { enum_name: string; enum_value: string }[]; + selectedItem: string | null; + onSelect: (key: string) => void; +} + +export const EnumSection = ({ dbName, schemaName, enumTypes, selectedItem, onSelect }: EnumSectionProps) => { + const groupedEnums = Array.from(new Set(enumTypes.map((e) => e.enum_name))); + + return ( + + + Enum Types + + {groupedEnums.map((enumName) => { + const values = enumTypes.filter((e) => e.enum_name === enumName).map((e) => e.enum_value); + const key = `${dbName}:::${schemaName}:::enum:::${enumName}`; + return ( + + + onSelect(key)} + > + + {enumName} + {values.length} + + + + + Values: + {values.map((v) => • {v})} + + + + ); + })} + + ); +}; + +// ---- Sequences ---- + +interface SequenceSectionProps { + dbName: string; + schemaName: string; + sequences: SequenceInfo[]; + selectedItem: string | null; + onSelect: (key: string) => void; +} + +export const SequenceSection = ({ dbName, schemaName, sequences, selectedItem, onSelect }: SequenceSectionProps) => ( + + + Sequences + + {sequences.map((seq) => { + const key = `${dbName}:::${schemaName}:::seq:::${seq.sequence_name}`; + return ( + + + onSelect(key)} + > + + {seq.sequence_name} + + + + + {seq.table_name && seq.column_name + ? Used by: {seq.table_name}.{seq.column_name} + : Standalone sequence + } + + + + ); + })} + +); \ No newline at end of file diff --git a/src/features/tree/TableTreeItem.tsx b/src/features/tree/TableTreeItem.tsx new file mode 100644 index 0000000..f3587f9 --- /dev/null +++ b/src/features/tree/TableTreeItem.tsx @@ -0,0 +1,118 @@ +// features/schema-explorer/components/tree/TableTreeItem.tsx + +import { ChevronDown, ChevronRight, Copy, Download, Eye, FileCode, Table } from "lucide-react"; +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ForeignKeyInfo } from "@/features/database/types"; +import { Column } from "../er-diagram/types"; +import { ColumnTreeItem } from "./ColumnTreeItem"; + +interface TableTreeItemProps { + table: any; + dbName: string; + schemaName: string; + isExpanded: boolean; + isSelected: boolean; + selectedItem: string | null; + onToggle: () => void; + onSelect: () => void; + onSelectItem: (key: string) => void; + onPreviewRows: (tableName: string) => void; + onShowDDL: (tableName: string) => void; + onCopy: (value: string, label: string) => void; + onExport: (tableName: string) => void; +} + +const getFkInfo = (column: Column, foreignKeys?: ForeignKeyInfo[]) => + foreignKeys?.find((fk) => fk.source_column === column.name); + +export const TableTreeItem = ({ + table, + dbName, + schemaName, + isExpanded, + isSelected, + selectedItem, + onToggle, + onSelect, + onSelectItem, + onPreviewRows, + onShowDDL, + onCopy, + onExport, +}: TableTreeItemProps) => { + const fkCount = table.foreignKeys?.length || 0; + + return ( + + + + {/* Table Row */} + { onToggle(); onSelect(); }} + > + {isExpanded + ? + : + } + + {table.name} + + {table.type !== "BASE TABLE" && ( + + VIEW + + )} + {fkCount > 0 && ( + + + + {fkCount} FK + + + Foreign Keys + + )} + + + + {/* Columns */} + {isExpanded && ( + + {table.columns.map((column: Column) => ( + idx.column_name === column.name && !idx.is_primary)} + isUnique={!!table.uniqueConstraints?.some((uc: any) => uc.column_name === column.name)} + onSelect={() => onSelectItem(`${dbName}:::${schemaName}:::${table.name}:::${column.name}`)} + onCopy={onCopy} + /> + ))} + + )} + + + + onPreviewRows(table.name)} className="hover:bg-accent"> + Preview Rows + + onShowDDL(table.name)} className="hover:bg-accent"> + Show DDL + + onCopy(table.name, "Table name")} className="hover:bg-accent"> + Copy Table Name + + onExport(table.name)} className="hover:bg-accent"> + Export Table + + + + ); +}; \ No newline at end of file diff --git a/src/features/tree/TreeViewPanel.tsx b/src/features/tree/TreeViewPanel.tsx new file mode 100644 index 0000000..a26d906 --- /dev/null +++ b/src/features/tree/TreeViewPanel.tsx @@ -0,0 +1,164 @@ +// features/schema-explorer/components/TreeViewPanel.tsx + +import { ChevronDown, ChevronRight, Database, Layers } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { DatabaseSchema } from "../schema-explorer/types"; +import { TableTreeItem } from "./TableTreeItem"; +import { EnumSection, SequenceSection } from "./SchemaExtras"; + +export interface TreeViewPanelProps { + database: DatabaseSchema; + expandedSchemas: Set; + expandedTables: Set; + toggleSchema: (schemaName: string) => void; + toggleTable: (tableName: string) => void; + selectedItem: string | null; + setSelectedItem: (itemPath: string) => void; + handlePreviewRows: (tableName: string) => void; + handleShowDDL: (tableName: string) => void; + handleCopy: (name: string, type: string) => void; + handleExport: (tableName: string) => void; +} + + +const TreeViewPanel = ({ + database, + expandedSchemas, + expandedTables, + toggleSchema, + toggleTable, + selectedItem, + setSelectedItem, + handlePreviewRows, + handleShowDDL, + handleCopy, + handleExport, +}: TreeViewPanelProps) => { + return ( + + + + + + {/* Database */} + + setSelectedItem(database.name)} + > + + {database.name} + + {database.schemas.length} schemas + + + + {/* Schemas */} + + {database.schemas.map((schema) => { + const schemaKey = `${database.name}:::${schema.name}`; + const isSchemaExpanded = expandedSchemas.has(schema.name); + const enumCount = schema.enumTypes?.length || 0; + const seqCount = schema.sequences?.length || 0; + + return ( + + {/* Schema Row */} + { toggleSchema(schema.name); setSelectedItem(schemaKey); }} + > + {isSchemaExpanded + ? + : + } + + {schema.name} + + {schema.tables.length} + {enumCount > 0 && ( + + + + {enumCount} E + + + Enum Types + + )} + {seqCount > 0 && ( + + + + {seqCount} S + + + Sequences + + )} + + + + {/* Schema Contents */} + {isSchemaExpanded && ( + + {schema.tables.map((table) => ( + toggleTable(table.name)} + onSelect={() => setSelectedItem(`${database.name}:::${schema.name}:::${table.name}`)} + onSelectItem={setSelectedItem} + onPreviewRows={handlePreviewRows} + onShowDDL={handleShowDDL} + onCopy={handleCopy} + onExport={handleExport} + /> + ))} + + {schema.enumTypes && schema.enumTypes.length > 0 && ( + + )} + + {schema.sequences && schema.sequences.length > 0 && ( + + )} + + )} + + ); + })} + + + + + + + + ); +}; + +export default TreeViewPanel; \ No newline at end of file diff --git a/src/features/tree/index.ts b/src/features/tree/index.ts new file mode 100644 index 0000000..1024d6c --- /dev/null +++ b/src/features/tree/index.ts @@ -0,0 +1 @@ +export { default as TreeViewPanel } from './TreeViewPanel'; \ No newline at end of file diff --git a/src/components/workspace/QueryTabBar.tsx b/src/features/workspace/components/QueryTabBar.tsx similarity index 98% rename from src/components/workspace/QueryTabBar.tsx rename to src/features/workspace/components/QueryTabBar.tsx index a7dd664..c5044dd 100644 --- a/src/components/workspace/QueryTabBar.tsx +++ b/src/features/workspace/components/QueryTabBar.tsx @@ -12,7 +12,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { QueryTab } from "./types"; +import { QueryTab } from "../types"; interface QueryTabBarProps { tabs: QueryTab[]; diff --git a/src/components/workspace/ResultsPanel.tsx b/src/features/workspace/components/ResultsPanel.tsx similarity index 97% rename from src/components/workspace/ResultsPanel.tsx rename to src/features/workspace/components/ResultsPanel.tsx index 1ddf871..b666d7d 100644 --- a/src/components/workspace/ResultsPanel.tsx +++ b/src/features/workspace/components/ResultsPanel.tsx @@ -1,7 +1,7 @@ import { Database, AlertCircle, Loader2, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DataTable } from "@/components/common/DataTable"; -import { QueryTab } from "./types"; +import { DataTable } from "@/components/shared/DataTable"; +import { QueryTab } from "../types"; interface ResultsPanelProps { activeTab: QueryTab | undefined; diff --git a/src/components/workspace/SQLWorkspacePanel.tsx b/src/features/workspace/components/SQLWorkspacePanel.tsx similarity index 95% rename from src/components/workspace/SQLWorkspacePanel.tsx rename to src/features/workspace/components/SQLWorkspacePanel.tsx index 8e5557f..9a92af8 100644 --- a/src/components/workspace/SQLWorkspacePanel.tsx +++ b/src/features/workspace/components/SQLWorkspacePanel.tsx @@ -1,18 +1,14 @@ import { useState, useEffect, useCallback } from "react"; -import { useBridgeQuery } from "@/hooks/useBridgeQuery"; -import { useDatabaseDetails } from "@/hooks/useDatabaseDetails"; +import { useBridgeQuery } from "@/services/bridge/useBridgeQuery"; +import { useDatabaseDetails } from "@/features/database/hooks/useDatabaseDetails"; import { Spinner } from "@/components/ui/spinner"; -import { SqlEditor } from "@/components/workspace/SqlEditor"; - -// Sub-components import { WorkspaceHeader } from "./WorkspaceHeader"; import { WorkspaceSidebar } from "./WorkspaceSidebar"; import { QueryTabBar } from "./QueryTabBar"; import { ResultsPanel } from "./ResultsPanel"; import { StatusBar } from "./StatusBar"; - -// Types -import { QueryTab, QueryHistoryItem } from "./types"; +import { QueryTab, QueryHistoryItem } from "../types"; +import { SqlEditor } from "./SqlEditor"; interface SQLWorkspacePanelProps { dbId: string; @@ -201,7 +197,7 @@ const SQLWorkspacePanel = ({ dbId }: SQLWorkspacePanelProps) => { {/* Split View: Editor + Results */} {/* Editor */} - + { const headerHeight = 40; const columnHeight = 28; const footerHeight = 30; - + const width = Math.max(baseWidth, table.name.length * 10 + 80); const height = headerHeight + (table.columns.length * columnHeight) + footerHeight; - + return { width, height }; }; @@ -44,8 +44,8 @@ const applyDagreLayout = ( ): Node[] => { const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); - dagreGraph.setGraph({ - rankdir: direction, + dagreGraph.setGraph({ + rankdir: direction, nodesep: 80, ranksep: 150, marginx: 50, @@ -54,9 +54,9 @@ const applyDagreLayout = ( // Add nodes to dagre nodes.forEach((node) => { - dagreGraph.setNode(node.id, { - width: node.width || 220, - height: node.height || 200 + dagreGraph.setNode(node.id, { + width: node.width || 220, + height: node.height || 200 }); }); @@ -89,7 +89,7 @@ export const transformSchemaToER = ( const nodes: Node[] = []; const edges: Edge[] = []; let nodeIndex = 0; - + // Colors const PRIMARY_CYAN = "#06B6D4"; // Tailwind cyan-500 const SCHEMA_COLORS: Record = { @@ -117,7 +117,7 @@ export const transformSchemaToER = ( // First pass: Create all nodes with enriched column data schema.schemas.forEach((schemaGroup) => { const schemaColor = SCHEMA_COLORS[schemaGroup.name] || "#6B7280"; - + schemaGroup.tables.forEach((table) => { const tableName = `${schemaGroup.name}.${table.name}`; const fkMap = buildFkMap(table.foreignKeys); @@ -182,8 +182,8 @@ export const transformSchemaToER = ( sourceHandle: sourceHandleId, targetHandle: targetHandleId, animated: false, - style: { - stroke: PRIMARY_CYAN, + style: { + stroke: PRIMARY_CYAN, strokeWidth: 2, }, // Crow's foot marker for "many" side (source) diff --git a/src/main.tsx b/src/main.tsx index c977fee..ef58faa 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,12 +7,12 @@ import Index from "./pages/Index"; import DatabaseDetail from './pages/DatabaseDetails'; import Projects from './pages/Projects'; import NotFound from './pages/NotFound'; -import { ThemeProvider } from './components/common/ThemeProvider'; +import { ThemeProvider } from './components/providers/ThemeProvider'; import Settings from './pages/Settings'; -import { useBridgeInit } from "@/hooks/useBridgeInit"; +import { useBridgeInit } from "@/services/bridge/useBridgeInit"; import { useEffect } from 'react'; -import { DeveloperContextMenu } from './components/common/DeveloperContextMenu'; -import { UpdateNotification } from './components/common/UpdateNotification'; +import { DeveloperContextMenu } from './components/dev/DeveloperContextMenu'; +import { UpdateNotification } from './components/shared/UpdateNotification'; const queryClient = new QueryClient(); @@ -35,7 +35,7 @@ function ThemeVariantInitializer() { } -import TitleBar from './components/common/TitleBar'; +import TitleBar from './components/layout/TitleBar'; function AppRoot() { useEffect(() => { diff --git a/src/pages/DatabaseDetails.tsx b/src/pages/DatabaseDetails.tsx index a2f86cf..67804f1 100644 --- a/src/pages/DatabaseDetails.tsx +++ b/src/pages/DatabaseDetails.tsx @@ -1,62 +1,51 @@ -import { useState } from "react"; +// pages/DatabaseDetail.tsx + import { useParams } from "react-router-dom"; -import { RefreshCw, Download, FileText, ChevronDown, PanelLeftClose, PanelLeft } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useBridgeQuery } from "@/hooks/useBridgeQuery"; -import { useDatabaseDetails } from "@/hooks/useDatabaseDetails"; -import { useMigrations, useFullSchema } from "@/hooks/useDbQueries"; -import { useExport } from "@/hooks/useExport"; -import { useProjectSync } from "@/hooks/useProjectSync"; -import { useProjectDir } from "@/hooks/useProjectQueries"; +import { useBridgeQuery } from "@/services/bridge/useBridgeQuery"; +import { useDatabaseDetails } from "@/features/database/hooks/useDatabaseDetails"; +import { useDatabaseDetailPage } from "@/features/database/hooks/useDatabaseDetailPage"; +import { useMigrations, useFullSchema } from "@/features/project/hooks/useDbQueries"; +import { useExport } from "@/features/database/hooks/useExport"; +import { useProjectSync } from "@/features/project/hooks/useProjectSync"; +import { useProjectDir } from "@/features/project/hooks/useProjectQueries"; +import { useRowOperations } from "@/features/database/hooks/useRowOperations"; import BridgeLoader from "@/components/feedback/BridgeLoader"; -import { Spinner } from "@/components/ui/spinner"; -import VerticalIconBar, { PanelType } from "@/components/common/VerticalIconBar"; -import SlideOutPanel from "@/components/common/SlideOutPanel"; -import TablesExplorerPanel from "@/components/database/TablesExplorerPanel"; -import ContentViewerPanel from "@/components/database/ContentViewerPanel"; -import { MigrationsPanel } from "@/components/database"; -import InsertDataDialog from "@/components/database/InsertDataDialog"; -import EditRowDialog from "@/components/database/EditRowDialog"; -import ConfirmDialog from "@/components/common/ConfirmDialog"; -import { ChartVisualization } from "@/components/chart/ChartVisualization"; -import { bridgeApi } from "@/services/bridgeApi"; -import { toast } from "sonner"; -import { cn } from "@/lib/utils"; -import SQLWorkspacePanel from "@/components/workspace/SQLWorkspacePanel"; -import QueryBuilderPanel from "@/components/query-builder/QueryBuilderPanel"; -import SchemaExplorerPanel from "@/components/schema-explorer/SchemaExplorerPanel"; -import ERDiagramPanel from "@/components/er-diagram/ERDiagramPanel"; -import GitStatusPanel from "@/components/git/GitStatusPanel"; -import GitStatusBar from "@/components/common/GitStatusBar"; +import VerticalIconBar from "@/components/layout/VerticalIconBar"; +import SlideOutPanel from "@/components/layout/SlideOutPanel"; +import ConfirmDialog from "@/components/shared/ConfirmDialog"; + +import { DatabaseErrorView } from "@/features/database/components/DatabaseErrorView"; +import { DataViewPanel } from "@/features/database/components/DataViewPanel"; +import { MigrationsPanel } from "@/features/database/components"; +import InsertDataDialog from "@/features/database/components/InsertDataDialog"; +import EditRowDialog from "@/features/database/components/EditRowDialog"; +import { ChartVisualization } from "@/features/chart/components"; +import ERDiagramPanel from "@/features/er-diagram/components/ERDiagramPanel"; +import { QueryBuilderPanel } from "@/features/query-builder/components"; +import { SchemaExplorerPanel } from "@/features/schema-explorer/components"; +import SQLWorkspacePanel from "@/features/workspace/components/SQLWorkspacePanel"; +import GitStatusPanel from "@/features/git/components/GitStatusPanel"; +import GitStatusBar from "@/features/git/components/GitStatusBar"; const DatabaseDetail = () => { const { id: dbId } = useParams<{ id: string }>(); const { data: bridgeReady, isLoading: bridgeLoading } = useBridgeQuery(); - const [activePanel, setActivePanel] = useState('data'); - const [migrationsOpen, setMigrationsOpen] = useState(false); - const [chartOpen, setChartOpen] = useState(false); - const [insertDialogOpen, setInsertDialogOpen] = useState(false); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [editingRow, setEditingRow] = useState | null>(null); - const [primaryKeyColumn, setPrimaryKeyColumn] = useState(""); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deletingRow, setDeletingRow] = useState | null>(null); - const [deleteRowPK, setDeleteRowPK] = useState(""); - const [deleteHasPK, setDeleteHasPK] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [searchResults, setSearchResults] = useState[] | null>(null); - const [searchResultCount, setSearchResultCount] = useState(undefined); - const [isSearching, setIsSearching] = useState(false); - const [searchPage, setSearchPage] = useState(1); - const [sidebarOpen, setSidebarOpen] = useState(true); + // Page UI state + const { + activePanel, + setActivePanel, + migrationsOpen, + setMigrationsOpen, + chartOpen, + setChartOpen, + insertDialogOpen, + setInsertDialogOpen, + sidebarOpen, + toggleSidebar, + } = useDatabaseDetailPage(); + + // Core database data const { databaseName, tables, @@ -65,7 +54,6 @@ const DatabaseDetail = () => { totalRows, currentPage, pageSize, - loading, loadingTables, isLoadingData, error, @@ -77,332 +65,86 @@ const DatabaseDetail = () => { schemas, selectedSchema, setSelectedSchema, - } = useDatabaseDetails({ - dbId, - bridgeReady: bridgeReady ?? false, + } = useDatabaseDetails({ dbId, bridgeReady: bridgeReady ?? false }); + + // Row operations — search, edit, delete + const rowOps = useRowOperations({ + dbId: dbId || "", + selectedTable, + pageSize, + refetchTableData, }); + // Export const { exportAllTables, isExporting } = useExport({ dbId: dbId || "", databaseName: databaseName || "database", }); + // Migrations const { data: migrationsResponse } = useMigrations(dbId); - const migrationsData = migrationsResponse?.migrations || { - local: [], - applied: [], - }; + const migrationsData = migrationsResponse?.migrations || { local: [], applied: [] }; const baselined = migrationsResponse?.baselined || false; - // --- Project auto-sync --- - // Fetches schema (React Query deduplicates with child components) - // and auto-saves to the linked project's JSON files in the background. + // Project sync const { data: schemaData } = useFullSchema(dbId); const { projectId } = useProjectSync(dbId, schemaData ?? undefined); const { data: projectDir } = useProjectDir(projectId); - if (bridgeLoading || bridgeReady === undefined) { - return ; - } - - if (error) { - return ( - - - - Connection Error - - - - Failed to connect to the database: - - - {error} - - - - {loadingTables ? ( - <> - - Retrying... - > - ) : ( - <> - - Retry - > - )} - - - - - - ); - } + // ---- Guards ---- + if (bridgeLoading || bridgeReady === undefined) return ; + if (error) return ; - // Render the active panel content - const renderPanelContent = () => { + // ---- Panel router ---- + const renderPanel = () => { switch (activePanel) { - case 'sql-workspace': - return ; - case 'query-builder': - return ; - case 'schema-explorer': - return ; - case 'er-diagram': - return ; - case 'git-status': - return ; - case 'data': + case "sql-workspace": return ; + case "query-builder": return ; + case "schema-explorer": return ; + case "er-diagram": return ; + case "git-status": return ; default: return ( - <> - {/* Header for Data View */} - - - - setSidebarOpen(!sidebarOpen)} - title={sidebarOpen ? "Hide sidebar" : "Show sidebar"} - > - {sidebarOpen ? ( - - ) : ( - - )} - - - - {databaseName || 'Database'} - {schemas.length > 0 && ( - - - - {selectedSchema} - - - - - {schemas.map(s => ( - setSelectedSchema(s)}> - {s} - - ))} - - - )} - - - {tables.length} tables in {selectedSchema} - - - - - - setMigrationsOpen(true)} - className="text-xs" - > - - Migrations - - - - - {isExporting ? ( - <> - - Exporting... - > - ) : ( - <> - - Export - - > - )} - - - - exportAllTables("csv")}> - Export as CSV - - exportAllTables("json")}> - Export as JSON - - - - - {loadingTables ? ( - - ) : ( - - )} - - - - - - {/* Content Area for Data View */} - - {/* Sidebar */} - - - - - {/* Main Content */} - - { - if (searchResults !== null && searchTerm) { - setSearchPage(1); - bridgeApi.searchTable({ - dbId: dbId || "", - schemaName: selectedTable?.schema || "public", - tableName: selectedTable?.name || "", - searchTerm, - page: 1, - pageSize, - }).then(result => { - setSearchResults(result.rows); - setSearchResultCount(result.total); - }).catch(() => { }); - } else { - fetchTables(); - } - }} - onPageChange={async (page) => { - if (searchResults !== null && searchTerm && selectedTable && dbId) { - setSearchPage(page); - setIsSearching(true); - try { - const result = await bridgeApi.searchTable({ - dbId, - schemaName: selectedTable.schema || "public", - tableName: selectedTable.name, - searchTerm, - page, - pageSize, - }); - setSearchResults(result.rows); - setSearchResultCount(result.total); - } finally { - setIsSearching(false); - } - } else { - handlePageChange(page); - } - }} - onPageSizeChange={handlePageSizeChange} - onChart={() => setChartOpen(true)} - onInsert={() => setInsertDialogOpen(true)} - searchTerm={searchTerm} - onSearchChange={(term) => { - setSearchTerm(term); - if (!term) { - setSearchResults(null); - setSearchResultCount(undefined); - } - }} - onSearch={async () => { - if (!searchTerm || !selectedTable || !dbId) return; - setIsSearching(true); - setSearchPage(1); - try { - const result = await bridgeApi.searchTable({ - dbId, - schemaName: selectedTable.schema || "public", - tableName: selectedTable.name, - searchTerm, - page: 1, - pageSize, - }); - setSearchResults(result.rows); - setSearchResultCount(result.total); - } catch (err: any) { - toast.error(err.message || "Search failed"); - setSearchResults(null); - setSearchResultCount(undefined); - } finally { - setIsSearching(false); - } - }} - isSearching={isSearching} - searchResultCount={searchResultCount} - onEditRow={async (row) => { - try { - let pk = ""; - try { - pk = await bridgeApi.getPrimaryKeys( - dbId || "", - selectedTable?.schema || "public", - selectedTable?.name || "" - ); - } catch { - pk = Object.keys(row)[0] || ""; - } - setPrimaryKeyColumn(pk); - setEditingRow(row); - setEditDialogOpen(true); - } catch (err: any) { - toast.error("Cannot edit: " + (err.message || "Unknown error")); - } - }} - onDeleteRow={async (row) => { - try { - let pk = ""; - let hasPK = false; - try { - pk = await bridgeApi.getPrimaryKeys( - dbId || "", - selectedTable?.schema || "public", - selectedTable?.name || "" - ); - hasPK = !!pk; - } catch { - hasPK = false; - } - setDeletingRow(row); - setDeleteRowPK(pk); - setDeleteHasPK(hasPK); - setDeleteDialogOpen(true); - } catch (err: any) { - toast.error(err.message || "Failed to prepare delete"); - } - }} - /> - - - > + setMigrationsOpen(true)} + onExport={exportAllTables} + isExporting={isExporting} + onChart={() => setChartOpen(true)} + onInsert={() => setInsertDialogOpen(true)} + onTableSelect={handleTableSelect} + onPageChange={handlePageChange} + onPageSizeChange={handlePageSizeChange} + // Search + searchTerm={rowOps.searchTerm} + searchResults={rowOps.searchResults} + searchResultCount={rowOps.searchResultCount} + isSearching={rowOps.isSearching} + searchPage={rowOps.searchPage} + isSearchActive={rowOps.isSearchActive} + onSearchChange={rowOps.handleSearchChange} + onSearch={rowOps.handleSearch} + onSearchPageChange={rowOps.handleSearchPageChange} + onSearchRefresh={rowOps.handleSearchRefresh} + // Row ops + onEditRow={rowOps.handleEditRow} + onDeleteRow={rowOps.handleDeleteRow} + /> ); } }; @@ -415,14 +157,13 @@ const DatabaseDetail = () => { activePanel={activePanel} onPanelChange={setActivePanel} /> - - - {renderPanelContent()} + + {renderPanel()} - {/* Bottom status bar with git info */} - + {/* Status bar */} + @@ -430,94 +171,54 @@ const DatabaseDetail = () => { - {/* Migrations Panel */} - setMigrationsOpen(false)} - title="Migrations" - disableScroll={true} - > - + {/* Migrations */} + setMigrationsOpen(false)} title="Migrations" disableScroll> + - {/* Chart Panel */} - setChartOpen(false)} - title={`Chart: ${selectedTable?.name || 'Table'}`} - width="60%" - > - {selectedTable && ( - - )} + {/* Chart */} + setChartOpen(false)} title={`Chart: ${selectedTable?.name || "Table"}`} width="60%"> + {selectedTable && } - {/* Insert Dialog */} + {/* Insert */} { - refetchTableData(); - setInsertDialogOpen(false); - }} + onSuccess={() => { refetchTableData(); setInsertDialogOpen(false); }} /> - {/* Edit Dialog */} + {/* Edit */} { - refetchTableData(); - setEditDialogOpen(false); - }} + rowData={rowOps.editingRow || {}} + primaryKeyColumn={rowOps.primaryKeyColumn} + onSuccess={rowOps.handleEditSuccess} /> - {/* Delete Confirm Dialog */} + {/* Delete */} { - if (!deletingRow || !selectedTable || !dbId) return; - try { - await bridgeApi.deleteRow({ - dbId, - schemaName: selectedTable.schema || "public", - tableName: selectedTable.name, - primaryKeyColumn: deleteRowPK, - primaryKeyValue: deletingRow[deleteRowPK], - }); - toast.success("Row deleted"); - refetchTableData(); - } catch (err: any) { - toast.error(err.message || "Delete failed"); - } - }} + onConfirm={rowOps.handleConfirmDelete} /> ); }; -export default DatabaseDetail; +export default DatabaseDetail; \ No newline at end of file diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 11aae45..78610ac 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,283 +1,146 @@ -import { useState, useCallback } from "react"; -import { toast } from "sonner"; -import { useNavigate } from "react-router-dom"; -import { bridgeApi } from "@/services/bridgeApi"; -import { useBridgeQuery } from "@/hooks/useBridgeQuery"; -import { - useDatabases, - useAddDatabase, - useDeleteDatabase, - usePrefetch -} from "@/hooks/useDbQueries"; +// pages/Index.tsx + +import { useBridgeQuery } from "@/services/bridge/useBridgeQuery"; import { - useCachedConnectionStatus, - useCachedTotalStats, - useCachedDbStats, -} from "@/hooks/useCachedData"; + ConnectionList, + DatabaseDetail, + WelcomeView, + AddConnectionDialog, + DeleteDialog, +} from "@/features/home/components"; import BridgeLoader from "@/components/feedback/BridgeLoader"; import BridgeFailed from "@/components/feedback/BridgeFailed"; -import VerticalIconBar from "@/components/common/VerticalIconBar"; -import { bytesToMBString } from "@/lib/bytesToMB"; -import { useQuery } from "@tanstack/react-query"; -import { - ConnectionList, - DatabaseDetail, - WelcomeView, - AddConnectionDialog, - DeleteDialog, - REQUIRED_FIELDS, - SQLITE_REQUIRED_FIELDS, - ConnectionFormData, -} from "@/components/home"; +import VerticalIconBar from "@/components/layout/VerticalIconBar"; +import { useIndexPage } from "@/features/home/hooks/useIndexPage"; const Index = () => { - const navigate = useNavigate(); - const { data: bridgeReady, isLoading: bridgeLoading } = useBridgeQuery(); - - // Caching hooks - load cached data instantly - const { cachedStatus, updateCache: updateStatusCache } = useCachedConnectionStatus(); - const { cachedStats, updateCache: updateStatsCache } = useCachedTotalStats(); - const { getStats: getCachedDbStats, updateCache: updateDbStatsCache } = useCachedDbStats(); - - const { data: databases = [], isLoading: loading, refetch: refetchDatabases } = useDatabases(); - - const { data: stats, isLoading: statsLoading, refetch: refetchStats } = useQuery({ - queryKey: ["totalStats"], - queryFn: async () => { - const result = await bridgeApi.getTotalDatabaseStats(); - // Update cache when fresh data arrives - if (result) updateStatsCache(result); - return result; - }, - enabled: !!bridgeReady && databases.length > 0, - staleTime: 30 * 1000, - }); - - const { data: statusData, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ - queryKey: ["connectionStatus"], - queryFn: async () => { - const res = await bridgeApi.testAllConnections(); - const statusMap = new Map(); - res.forEach((r) => statusMap.set(r.id, r.result.status)); - // Update cache when fresh data arrives - updateStatusCache(statusMap); - return statusMap; - }, - enabled: !!bridgeReady, - staleTime: 60 * 1000, - }); - - const addDatabaseMutation = useAddDatabase(); - const deleteDatabaseMutation = useDeleteDatabase(); - const { prefetchTables, prefetchStats } = usePrefetch(); - - const [searchQuery, setSearchQuery] = useState(""); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [dbToDelete, setDbToDelete] = useState<{ id: string; name: string } | null>(null); - const [selectedDb, setSelectedDb] = useState(null); - const [prefilledConnectionData, setPrefilledConnectionData] = useState | undefined>(undefined); - - // Use fresh data if available, fall back to cached data - const status = statusData || cachedStatus; - const effectiveStats = stats || cachedStats; - const totalSize = effectiveStats?.sizeBytes ? bytesToMBString(effectiveStats.sizeBytes) : "—"; - const totalTables = effectiveStats?.tables ?? "—"; - const connectedCount = [...status.values()].filter(s => s === "connected").length; - - // Show loading state only if we have no cached data - const showStatsLoading = statsLoading && !cachedStats; - const showStatusLoading = statusLoading && cachedStatus.size === 0; - - // Fetch stats for the selected database - const { data: selectedDbStats, isLoading: selectedDbStatsLoading } = useQuery({ - queryKey: ["dbStats", selectedDb], - queryFn: async () => { - const result = await bridgeApi.getDataBaseStats(selectedDb!); - // Update cache when fresh data arrives - if (result && selectedDb) updateDbStatsCache(selectedDb, result); - return result; - }, - enabled: !!bridgeReady && !!selectedDb && status.get(selectedDb) === "connected", - staleTime: 30 * 1000, - }); - - // Get cached stats for selected db - const cachedSelectedDbStats = selectedDb ? getCachedDbStats(selectedDb) : undefined; - const effectiveSelectedDbStats = selectedDbStats || cachedSelectedDbStats; - - const recentDatabases = [...databases] - .filter(db => db.lastAccessedAt) - .sort((a, b) => new Date(b.lastAccessedAt!).getTime() - new Date(a.lastAccessedAt!).getTime()) - .slice(0, 5); + const { data: bridgeReady, isLoading: bridgeLoading } = useBridgeQuery(); - const filteredDatabases = databases.filter(db => - db.name.toLowerCase().includes(searchQuery.toLowerCase()) || - db.host.toLowerCase().includes(searchQuery.toLowerCase()) - ); + // Bridge guard — only logic allowed in page + if (bridgeLoading || bridgeReady === undefined) return ; + if (!bridgeReady) return ; + return ; +}; - - const handleAddDatabase = async (formData: ConnectionFormData) => { - const isSQLite = formData.type === "sqlite"; - const requiredFields = isSQLite ? SQLITE_REQUIRED_FIELDS : REQUIRED_FIELDS; - const missing = requiredFields.filter(field => !formData[field as keyof typeof formData]); - if (missing.length) { - toast.error("Missing required fields", { description: `Please fill in: ${missing.join(", ")}` }); - return; - } - try { - await addDatabaseMutation.mutateAsync({ - ...formData, - port: isSQLite ? 0 : parseInt(formData.port), - sslmode: isSQLite ? "disable" : (formData.ssl ? (formData.sslmode || "require") : "disable") - }); - toast.success("Database connection added"); - setIsDialogOpen(false); - await Promise.all([refetchStats(), refetchStatus()]); - } catch (err: any) { - toast.error("Failed to add database", { description: err.message }); - } - }; - - const handleDeleteDatabase = async () => { - if (!dbToDelete) return; - try { - await deleteDatabaseMutation.mutateAsync(dbToDelete.id); - toast.success("Database removed"); - setDeleteDialogOpen(false); - setDbToDelete(null); - if (selectedDb === dbToDelete.id) setSelectedDb(null); - refetchStats(); - } catch (err: any) { - toast.error("Failed to delete", { description: err.message }); - } - }; - - const handleTestConnection = async (id: string, name: string) => { - const result = await bridgeApi.testConnection(id); - if (result.ok) { - toast.success("Connected", { description: name }); - refetchStatus(); - } else { - toast.error("Failed", { description: result.message || "Could not connect" }); - } - }; - - const handleDatabaseClick = (dbId: string) => { - bridgeApi.touchDatabase(dbId); - navigate(`/${dbId}`); - }; - - const handleDatabaseHover = (dbId: string) => { - prefetchTables(dbId); - prefetchStats(dbId); - }; - - // Handler for when a discovered database is selected - const handleDiscoveredDatabaseAdd = useCallback((db: { type: string; host: string; port: number; suggestedName: string; defaultUser: string; defaultDatabase: string; defaultPassword?: string }) => { - setPrefilledConnectionData({ - name: db.suggestedName, - type: db.type, - host: db.host, - port: String(db.port), - user: db.defaultUser, - database: db.defaultDatabase, - password: db.defaultPassword || "", - ssl: false, - sslmode: "", - }); - setIsDialogOpen(true); - }, []); - - // Loading states - if (bridgeLoading || bridgeReady === undefined) return ; - if (!bridgeReady) return ; - - const selectedDatabase = selectedDb ? databases.find(db => db.id === selectedDb) : null; - const isSelectedConnected = selectedDb ? status.get(selectedDb) === "connected" : false; - - return ( - - - - {/* Left Panel - Database List */} - setIsDialogOpen(true)} - onDatabaseHover={handleDatabaseHover} - onDelete={(dbId, dbName) => { - setDbToDelete({ id: dbId, name: dbName }); - setDeleteDialogOpen(true); - }} - onTest={handleTestConnection} - /> - - {/* Right Panel - Main Content */} - - {selectedDatabase ? ( - handleTestConnection(selectedDatabase.id, selectedDatabase.name)} - onOpen={() => handleDatabaseClick(selectedDatabase.id)} - onDelete={() => { - setDbToDelete({ id: selectedDatabase.id, name: selectedDatabase.name }); - setDeleteDialogOpen(true); - }} +// Separated so hooks only run after bridge is ready +const IndexContent = ({ bridgeReady }: { bridgeReady: boolean }) => { + const { + // Data + databases, + filteredDatabases, + recentDatabases, + selectedDatabase, + selectedDbStats, + loading, + welcomeMessage, + + // Status + stats + status, + totalSize, + totalTables, + connectedCount, + showStatsLoading, + isSelectedConnected, + + // Mutation states + isAdding, + + // UI state + searchQuery, + setSearchQuery, + selectedDb, + setSelectedDb, + isDialogOpen, + deleteDialogOpen, + setDeleteDialogOpen, + dbToDelete, + prefilledConnectionData, + + // Handlers + handleAddDatabase, + handleDeleteDatabase, + handleTestConnection, + handleDatabaseClick, + handleDatabaseHover, + handleDiscoveredDatabaseAdd, + handleDialogClose, + openDeleteDialog, + } = useIndexPage(bridgeReady); + + return ( + + + + + {/* Left Panel */} + handleDialogClose(true)} + onDatabaseHover={handleDatabaseHover} + onDelete={openDeleteDialog} + onTest={handleTestConnection} + /> + + {/* Right Panel */} + + {selectedDatabase ? ( + handleTestConnection(selectedDatabase.id, selectedDatabase.name)} + onOpen={() => handleDatabaseClick(selectedDatabase.id)} + onDelete={() => openDeleteDialog(selectedDatabase.id, selectedDatabase.name)} + /> + ) : ( + handleDialogClose(true)} + onSelectDb={setSelectedDb} + onDatabaseClick={handleDatabaseClick} + onDatabaseHover={handleDatabaseHover} + onDiscoveredDatabaseAdd={handleDiscoveredDatabaseAdd} + /> + )} + + + + {/* Dialogs */} + - ) : ( - setIsDialogOpen(true)} - onSelectDb={setSelectedDb} - onDatabaseClick={handleDatabaseClick} - onDatabaseHover={handleDatabaseHover} - onDiscoveredDatabaseAdd={handleDiscoveredDatabaseAdd} + + - )} - - - {/* Add Database Dialog */} - { - setIsDialogOpen(open); - if (!open) setPrefilledConnectionData(undefined); - }} - onSubmit={(formData) => handleAddDatabase(formData)} - isLoading={addDatabaseMutation.isPending} - initialData={prefilledConnectionData} - /> - - {/* Delete Dialog */} - - - ); + ); }; -export default Index; +export default Index; \ No newline at end of file diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index d32a0af..b71aa56 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -1,161 +1,59 @@ -import { useState, useMemo } from "react"; -import { toast } from "sonner"; -import { useNavigate } from "react-router-dom"; -import { useQueryClient } from "@tanstack/react-query"; -import { useBridgeQuery } from "@/hooks/useBridgeQuery"; -import { useDatabases, queryKeys } from "@/hooks/useDbQueries"; -import { - useProjects, - useCreateProject, - useDeleteProject, - useProjectSchema, - useProjectERDiagram, - useProjectQueries, - projectKeys, -} from "@/hooks/useProjectQueries"; -import { bridgeApi } from "@/services/bridgeApi"; -import BridgeLoader from "@/components/feedback/BridgeLoader"; -import BridgeFailed from "@/components/feedback/BridgeFailed"; -import VerticalIconBar from "@/components/common/VerticalIconBar"; +import { useBridgeQuery } from "@/services/bridge/useBridgeQuery"; +import { useProjectsPage } from "@/features/project/hooks/useProjectsPage"; import { ProjectList, CreateProjectDialog, DeleteProjectDialog, ImportProjectDialog, ProjectDetailView, -} from "@/components/project"; -import { FolderOpen, Sparkles } from "lucide-react"; -import { Button } from "@/components/ui/button"; +} from "@/features/project/components"; +import { ProjectsEmptyState } from "@/features/project/components/ProjectsEmptyState"; +import BridgeLoader from "@/components/feedback/BridgeLoader"; +import BridgeFailed from "@/components/feedback/BridgeFailed"; +import VerticalIconBar from "@/components/layout/VerticalIconBar"; const Projects = () => { - const navigate = useNavigate(); - const queryClient = useQueryClient(); const { data: bridgeReady, isLoading: bridgeLoading } = useBridgeQuery(); - // Data queries - const { data: projects = [], isLoading: projectsLoading } = useProjects(); - const { data: databases = [] } = useDatabases(); - - // Mutations - const createProjectMutation = useCreateProject(); - const deleteProjectMutation = useDeleteProject(); - - // Local state - const [searchQuery, setSearchQuery] = useState(""); - const [selectedProject, setSelectedProject] = useState(null); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [isImportOpen, setIsImportOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [projectToDelete, setProjectToDelete] = useState<{ - id: string; - name: string; - } | null>(null); - - // Sub-resource queries for the selected project - const { data: schemaData } = useProjectSchema(selectedProject ?? undefined); - const { data: erData } = useProjectERDiagram(selectedProject ?? undefined); - const { data: queriesData } = useProjectQueries(selectedProject ?? undefined); - - // Filtering - const filteredProjects = useMemo( - () => - projects.filter( - (p) => - p.name.toLowerCase().includes(searchQuery.toLowerCase()) || - (p.description ?? "").toLowerCase().includes(searchQuery.toLowerCase()) - ), - [projects, searchQuery] - ); - - const selectedProjectData = useMemo( - () => projects.find((p) => p.id === selectedProject) ?? null, - [projects, selectedProject] - ); - - // ---- Handlers ---- - - const handleCreate = async (data: { - databaseId: string; - name: string; - description?: string; - defaultSchema?: string; - }) => { - try { - const created = await createProjectMutation.mutateAsync(data); - toast.success("Project created", { description: created.name }); - setIsCreateOpen(false); - setSelectedProject(created.id); - } catch (err: any) { - toast.error("Failed to create project", { description: err.message }); - } - }; - - const handleDelete = async () => { - if (!projectToDelete) return; - try { - await deleteProjectMutation.mutateAsync(projectToDelete.id); - toast.success("Project deleted"); - setDeleteDialogOpen(false); - setProjectToDelete(null); - if (selectedProject === projectToDelete.id) setSelectedProject(null); - } catch (err: any) { - toast.error("Failed to delete", { description: err.message }); - } - }; - - const handleExport = async (projectId: string) => { - try { - const bundle = await bridgeApi.exportProject(projectId); - if (!bundle) { - toast.error("Project not found"); - return; - } - // Download as JSON file - const blob = new Blob([JSON.stringify(bundle, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${bundle.metadata.name.replace(/\s+/g, "-").toLowerCase()}-export.json`; - a.click(); - setTimeout(() => { - URL.revokeObjectURL(url); - }, 0); - toast.success("Project exported"); - } catch (err: any) { - toast.error("Export failed", { description: err.message }); - } - }; - - /** - * Called by ImportProjectDialog after it successfully creates the DB - * connection AND imports the project. We invalidate caches so the new - * project + database appear immediately in the UI. - */ - const handleImportComplete = (projectId: string, projectName: string) => { - queryClient.invalidateQueries({ queryKey: projectKeys.all }); - queryClient.invalidateQueries({ queryKey: queryKeys.databases }); - toast.success("Project imported", { description: projectName }); - setSelectedProject(projectId); - }; - - const handleOpen = (projectId: string) => { - const project = projects.find((p) => p.id === projectId); - if (project) { - // Navigate to the linked database detail page - navigate(`/${project.databaseId}`); - } - }; - - // ---- Loading / Error states ---- + // All logic lives in the hook + const { + projects, + databases, + filteredProjects, + selectedProjectData, + projectsLoading, + schemaData, + erData, + queriesData, + isCreating, + searchQuery, + setSearchQuery, + selectedProject, + setSelectedProject, + isCreateOpen, + setIsCreateOpen, + isImportOpen, + setIsImportOpen, + deleteDialogOpen, + setDeleteDialogOpen, + projectToDelete, + handleCreate, + handleDelete, + handleExport, + handleImportComplete, + handleOpen, + openDeleteDialog, + } = useProjectsPage(); + + // ---- Bridge guard — only logic allowed in page ---- if (bridgeLoading || bridgeReady === undefined) return ; if (!bridgeReady) return ; return ( - + + {/* Left panel */} { setSelectedProject={setSelectedProject} onCreateClick={() => setIsCreateOpen(true)} onImportClick={() => setIsImportOpen(true)} - onDelete={(id: string, name: string) => { - setProjectToDelete({ id, name }); - setDeleteDialogOpen(true); - }} + onDelete={openDeleteDialog} onOpen={handleOpen} /> @@ -183,36 +78,14 @@ const Projects = () => { queryCount={queriesData?.queries?.length} hasERLayout={(erData?.nodes?.length ?? 0) > 0} onOpen={() => handleOpen(selectedProjectData.id)} - onDelete={() => { - setProjectToDelete({ - id: selectedProjectData.id, - name: selectedProjectData.name, - }); - setDeleteDialogOpen(true); - }} + onDelete={() => openDeleteDialog(selectedProjectData.id, selectedProjectData.name)} onExport={() => handleExport(selectedProjectData.id)} /> ) : ( - /* Empty state */ - - - - - - - Projects - - Save database details, ER diagrams & queries offline - - - - {projects.length === 0 && ( - setIsCreateOpen(true)} className="mt-4"> - - Create Your First Project - - )} - + 0} + onCreateClick={() => setIsCreateOpen(true)} + /> )} @@ -222,7 +95,7 @@ const Projects = () => { open={isCreateOpen} onOpenChange={setIsCreateOpen} onSubmit={handleCreate} - isLoading={createProjectMutation.isPending} + isLoading={isCreating} databases={databases} /> diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ddef297..0b2cc20 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,329 +1,35 @@ -import { useState, useEffect } from "react"; -import { getVersion } from "@tauri-apps/api/app"; -import { useTheme } from "@/components/common/ThemeProvider"; -import { useThemeVariant } from "@/hooks/useThemeVariant"; -import { useDeveloperMode } from "@/hooks/useDeveloperMode"; -import { useUpdater } from "@/hooks/useUpdater"; -import { themeVariants, ThemeVariant } from "@/lib/themes"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { Palette, Moon, Sun, Monitor, Check, Code2, Bug, RefreshCw, Download, Loader2, CheckCircle2, AlertCircle, Info } from "lucide-react"; -import VerticalIconBar from "@/components/common/VerticalIconBar"; +import VerticalIconBar from "@/components/layout/VerticalIconBar"; +import { CheckForUpdates, ColorVariant, DeveloperMode, Header, Preview, ThemeMode, Version } from "@/features/settings/components"; const Settings = () => { - const { theme, setTheme } = useTheme(); - const { variant, setVariant } = useThemeVariant(); - const { isEnabled: devModeEnabled, setIsEnabled: setDevModeEnabled } = useDeveloperMode(); - const { status, updateInfo, downloadProgress, error: updateError, checkForUpdates, downloadAndInstall, relaunchApp } = useUpdater(); - - const [appVersion, setAppVersion] = useState(""); - - useEffect(() => { - getVersion().then(setAppVersion).catch(() => setAppVersion("unknown")); - }, []); - - const themeOptions = [ - { value: "light", label: "Light", icon: Sun }, - { value: "dark", label: "Dark", icon: Moon }, - { value: "system", label: "System", icon: Monitor }, - ] as const; - return ( - + {/* Header */} - - - - Settings - - Customize your app appearance - - - - + {/* Content */} {/* Theme Mode Section */} - - - - - Theme Mode - - Choose between light and dark mode - - - - - - {themeOptions.map((option) => { - const Icon = option.icon; - const isActive = theme === option.value; - - return ( - setTheme(option.value)} - className={` - relative p-4 rounded-lg border-2 transition-all - ${isActive - ? "border-primary bg-primary/5" - : "border-border/20 hover:border-border/40 bg-background" - } - `} - > - - - - {option.label} - - - {isActive && ( - - - - - - )} - - ); - })} - - + {/* Color Variant Section */} - - - - - Accent Color - - Select your preferred color theme - - - - - - {Object.entries(themeVariants).map(([key, config]) => { - const isActive = variant === key; - - return ( - setVariant(key as ThemeVariant)} - className={` - relative p-4 rounded-lg border-2 transition-all - ${isActive - ? "border-primary bg-primary/5" - : "border-border/20 hover:border-border/40 bg-background" - } - `} - > - - - - {config.name} - - - {isActive && ( - - - - - - )} - - ); - })} - - + {/* Developer Mode Section */} - - - - - - Developer Mode - - Enable developer tools and context menu options - - - - - - - {devModeEnabled && ( - - - - - Developer features enabled: - - Right-click context menu with Inspect, Reload, Back/Forward - Access to browser developer tools (F12) - Keyboard shortcuts for navigation - - - - - )} - + {/* Updates Section */} - - - - - - Updates - - Check for new versions of RelWave - - - - - {status === "idle" || status === "up-to-date" || status === "error" || status === "dev-mode" ? ( - - - Check for Updates - - ) : status === "checking" ? ( - - - Checking... - - ) : null} - - - {/* Status messages */} - {status === "up-to-date" && ( - - - - You're running the latest version. - - - )} - - {status === "dev-mode" && ( - - - - Update checks are disabled in development mode. - - - )} - - {status === "error" && updateError && ( - - - - {updateError} - - - )} - - {status === "available" && updateInfo && ( - - - - - v{updateInfo.version} available - {updateInfo.body && ( - {updateInfo.body} - )} - - - - Download & Install - - - - - )} - - {status === "downloading" && ( - - - Downloading update... - {downloadProgress}% - - - - - - )} - - {status === "ready" && ( - - - - - Update downloaded. Restart to apply. - - - Restart Now - - - - )} - + {/* About Section */} - - - - - About - - RelWave v{appVersion || "—"} - - - - + {/* Preview Section */} - - Preview - - - - Primary Button - - - Outline Button - - - Ghost Button - - - - - This is a preview of how text and UI elements will look with your selected theme. - - - - + diff --git a/src/services/bridge/bridge.ts b/src/services/bridge/bridge.ts new file mode 100644 index 0000000..36bc9f2 --- /dev/null +++ b/src/services/bridge/bridge.ts @@ -0,0 +1,36 @@ +import { bridgeRequest } from "./bridgeClient"; + +class BridgeService{ + + /** + * Ping the bridge to check if it's alive + */ + async ping(): Promise { + try { + const result = await bridgeRequest("ping", {}); + return result?.ok === true; + } catch (error) { + return false; + } + } + + /** + * Get bridge health status + */ + async healthCheck(): Promise<{ + ok: boolean; + uptimeSec: number; + pid: number; + }> { + try { + const result = await bridgeRequest("health.ping", {}); + return result?.data || { ok: false, uptimeSec: 0, pid: 0 }; + } catch (error: any) { + throw new Error(`Health check failed: ${error.message}`); + } + } + +} + + +export const bridgeService = new BridgeService(); \ No newline at end of file diff --git a/src/services/bridgeClient.ts b/src/services/bridge/bridgeClient.ts similarity index 100% rename from src/services/bridgeClient.ts rename to src/services/bridge/bridgeClient.ts diff --git a/src/services/bridge/database.ts b/src/services/bridge/database.ts new file mode 100644 index 0000000..425a265 --- /dev/null +++ b/src/services/bridge/database.ts @@ -0,0 +1,559 @@ +import { + AddDatabaseParams, + ConnectionTestResult, + CreateTableColumn, + DatabaseConnection, + DatabaseSchemaDetails, + DatabaseStats, + DiscoveredDatabase, + RunQueryParams, + TableRow, + UpdateDatabaseParams +} from '@/features/database/types'; import { bridgeRequest } from "./bridgeClient"; + +class DatabaseService { + // ------------------------------------ + // 3. DATABASE CRUD/METADATA METHODS (db.*) + // ------------------------------------ + + /** + * List all database connections + */ + async listDatabases(): Promise { + try { + const result = await bridgeRequest("db.list", {}); + return result?.data || []; + } catch (error: any) { + console.error("Failed to list databases:", error); + throw new Error(`Failed to list databases: ${error.message}`); + } + } + + /** + * Get a specific database connection by ID + */ + async getDatabase(id: string): Promise { + try { + const result = await bridgeRequest("db.get", { id }); + return result?.data || null; + } catch (error: any) { + console.error("Failed to get database:", error); + throw new Error(`Failed to get database: ${error.message}`); + } + } + + /** + * Get migrations data for a database + */ + async getMigrations(id: string): Promise<{ migrations: { local: any[]; applied: any[] }; baselined: boolean } | null> { + try { + const result = await bridgeRequest("query.connectToDatabase", { dbId: id }); + console.log(result) + return result?.result || null; + } catch (error: any) { + console.error("Failed to get migrations:", error); + throw new Error(`Failed to get migrations: ${error.message}`); + } + } + + /** + * Add a new database connection + */ + async addDatabase(params: AddDatabaseParams): Promise { + try { + // Validate required fields + const isSQLite = params.type === "sqlite"; + const required = isSQLite + ? ["name", "type", "database"] + : ["name", "type", "host", "port", "user", "database"]; + for (const field of required) { + if (!params[field as keyof AddDatabaseParams]) { + throw new Error(`Missing required field: ${field}`); + } + } + const result = await bridgeRequest("db.add", params); + if (!result?.ok) { + throw new Error("Failed to add database"); + } + + // Fetch the full database details + const dbId = result.data?.id; + if (!dbId) { + throw new Error("No database ID returned"); + } + + const database = await this.getDatabase(dbId); + if (!database) { + throw new Error("Failed to fetch created database"); + } + + return database; + } catch (error: any) { + console.error("Failed to add database:", error); + throw new Error(`Failed to add database: ${error.message}`); + } + } + + /** + * Update an existing database connection + */ + async updateDatabase(params: UpdateDatabaseParams): Promise { + try { + if (!params.id) { + throw new Error("Database ID is required"); + } + + await bridgeRequest("db.update", params); + } catch (error: any) { + console.error("Failed to update database:", error); + throw new Error(`Failed to update database: ${error.message}`); + } + } + + /** + * Delete a database connection + */ + async deleteDatabase(id: string): Promise { + try { + if (!id) { + throw new Error("Database ID is required"); + } + + await bridgeRequest("db.delete", { id }); + } catch (error: any) { + console.error("Failed to delete database:", error); + throw new Error(`Failed to delete database: ${error.message}`); + } + } + + /** + * Update the lastAccessedAt timestamp for a database + * @param id - Database ID to touch + */ + async touchDatabase(id: string): Promise { + try { + if (!id) return; + await bridgeRequest("db.touch", { id }); + } catch (error: any) { + // Silently fail - this is not critical + console.warn("Failed to update last accessed time:", error); + } + } + + /** + * Test connection to a database + * @param id - Database ID to test + */ + async testConnection(id: string): Promise { + try { + if (!id) { + throw new Error("Database ID is required"); + } + + const result = await bridgeRequest("db.connectTest", { id }); + console.log(result); + return result?.data || { ok: false, message: "Unknown error" }; + } catch (error: any) { + console.error("Failed to test connection:", error); + return { ok: false, message: error.message, status: 'disconnected' }; + } + } + + async testAllConnections(): Promise<{ id: string; result: ConnectionTestResult }[]> { + try { + const databases = await this.listDatabases(); + const results: { id: string; result: ConnectionTestResult }[] = []; + for (const db of databases) { + const testResult = await this.testConnection(db.id); + results.push({ id: db.id, result: testResult }); + } + return results; + } catch (error) { + console.log(error) + return []; + } + } + + /** + * Test connection with raw connection parameters (without saving) + */ + async testConnectionDirect(connection: { + host: string; + port: number; + user: string; + password?: string; + database: string; + }): Promise { + try { + const result = await bridgeRequest("db.connectTest", { connection }); + return result?.data || { ok: false, message: "Unknown error" }; + } catch (error: any) { + console.error("Failed to test connection:", error); + return { ok: false, message: error.message, status: 'disconnected' }; + } + } + + /** + * List all tables in a database + */ + async listTables(id: string, schema?: string): Promise { + // Changed return type to any[] to match typical result shape [{schema, name, type}] + try { + if (!id) { + throw new Error("Database ID is required"); + } + + const result = await bridgeRequest("db.listTables", { id, schema }); + return result?.data || []; + } catch (error: any) { + console.error("Failed to list tables:", error); + throw new Error(`Failed to list tables: ${error.message}`); + } + } + + async listSchemas(id: string): Promise { + try { + if (!id) { + throw new Error("Database ID is required"); + } + + const result = await bridgeRequest("db.listSchemas", { id }); + return result?.data || []; + } catch (error: any) { + console.error("Failed to list schemas:", error); + throw new Error(`Failed to list schemas: ${error.message}`); + } + } + + /** + * Alias for getDatabaseStats - used by useDbQueries hook + */ + async getDataBaseStats(id: string): Promise { + try { + if (!id) { + throw new Error("Database ID is required"); + } + const result = await bridgeRequest("db.getStats", { id }); + return result?.data || { tables: 0, rows: 0, sizeBytes: 0 }; + } catch (error: any) { + console.error("Failed to get database stats:", error); + throw new Error(`Failed to get database stats: ${error.message}`); + } + } + + async getTotalDatabaseStats(): Promise { + try { + const result = await bridgeRequest("db.getTotalStats", {}); + return result?.data || { row: 0, size: 0, tables: 0 }; + } catch (error) { + console.log(error); + throw new Error(`Failed to get total database stats: ${error}`); + } + } + + async getSchema(id: string): Promise { + try { + if (!id) { + throw new Error("Database ID is required."); + } + const result = await bridgeRequest("db.getSchema", { id }); + return result?.data || null; + } catch (error: any) { + console.error("Failed to fetch schema details:", error); + throw new Error(`Failed to fetch schema details: ${error.message}`); + } + } + + async getPrimaryKeys(id: string, schemaName: string, tableName: string): Promise { + try { + if (!id || !schemaName || !tableName) { + throw new Error("Database ID, schema name, and table name are required."); + } + const result = await bridgeRequest("query.listPrimaryKeys", { + dbId: id, + schemaName, + tableName, + }); + return result?.primaryKeys[0].column_name || result?.primaryKeys[0].COLUMN_NAME || result.primaryKeys[0] || ""; + } catch (error: any) { + console.error("Failed to fetch primary keys:", error); + throw new Error(`Failed to fetch primary keys: ${error.message}`); + } + } + + /** + * Create a new table in the database + */ + async createTable(params: { + dbId: string; + schemaName: string; + tableName: string; + columns: CreateTableColumn[]; + foreignKeys?: any[]; + }): Promise { + try { + if (!params.dbId || !params.schemaName || !params.tableName) { + throw new Error("Database ID, schema name, and table name are required."); + } + if (!params.columns || params.columns.length === 0) { + throw new Error("At least one column is required."); + } + const result = await bridgeRequest("query.createTable", { + dbId: params.dbId, + schemaName: params.schemaName, + tableName: params.tableName, + columns: params.columns, + foreignKeys: params.foreignKeys || [], + }); + return result?.ok === true; + } catch (error: any) { + console.error("Failed to create table:", error); + throw new Error(`Failed to create table: ${error.message}`); + } + } + + /** + * Create indexes for tables in the database + */ + async createIndexes(params: { + dbId: string; + schemaName: string; + indexes: any[]; + }): Promise { + try { + if (!params.dbId || !params.schemaName) { + throw new Error("Database ID and schema name are required."); + } + if (!params.indexes || params.indexes.length === 0) { + throw new Error("At least one index is required."); + } + + const result = await bridgeRequest("query.createIndexes", { + dbId: params.dbId, + schemaName: params.schemaName, + indexes: params.indexes, + }); + console.log(result) + return result?.ok === true; + } catch (error: any) { + console.error("Failed to create indexes:", error); + throw new Error(`Failed to create indexes: ${error.message}`); + } + } + + /** + * Alter table structure + */ + async alterTable(params: { + dbId: string; + schemaName: string; + tableName: string; + operations: any[]; + }): Promise { + try { + if (!params.dbId || !params.schemaName || !params.tableName) { + throw new Error("Database ID, schema name, and table name are required."); + } + if (!params.operations || params.operations.length === 0) { + throw new Error("At least one operation is required."); + } + + const result = await bridgeRequest("query.alterTable", { + dbId: params.dbId, + schemaName: params.schemaName, + tableName: params.tableName, + operations: params.operations, + }); + + return result?.ok === true; + } catch (error: any) { + console.error("Failed to alter table:", error); + throw new Error(`Failed to alter table: ${error.message}`); + } + } + + /** + * Drop a table + */ + async dropTable(params: { + dbId: string; + schemaName: string; + tableName: string; + mode?: "RESTRICT" | "DETACH_FKS" | "CASCADE"; + }): Promise { + try { + if (!params.dbId || !params.schemaName || !params.tableName) { + throw new Error("Database ID, schema name, and table name are required."); + } + + const result = await bridgeRequest("query.dropTable", { + dbId: params.dbId, + schemaName: params.schemaName, + tableName: params.tableName, + mode: params.mode || "RESTRICT", + }); + + return result?.ok === true; + } catch (error: any) { + console.error("Failed to drop table:", error); + throw new Error(`Failed to drop table: ${error.message}`); + } + } + + /** + * Insert a row into a table + */ + async insertRow(params: { + dbId: string; + schemaName: string; + tableName: string; + rowData: Record; + }): Promise { + try { + if (!params.dbId || !params.schemaName || !params.tableName) { + throw new Error("Database ID, schema name, and table name are required."); + } + if (!params.rowData || Object.keys(params.rowData).length === 0) { + throw new Error("Row data is required."); + } + + const result = await bridgeRequest("query.insertRow", { + dbId: params.dbId, + schemaName: params.schemaName, + tableName: params.tableName, + rowData: params.rowData, + }); + + return result?.result || result; + } catch (error: any) { + console.error("Failed to insert row:", error); + throw new Error(`Failed to insert row: ${error.message}`); + } + } + + /** + * Update a row in a table + */ + async updateRow(params: { + dbId: string; + schemaName: string; + tableName: string; + primaryKeyColumn: string; + primaryKeyValue: any; + rowData: Record; + }): Promise { + try { + if (!params.dbId || !params.schemaName || !params.tableName || !params.primaryKeyColumn) { + throw new Error("Database ID, schema name, table name, and primary key column are required."); + } + if (params.primaryKeyValue === undefined) { + throw new Error("Primary key value is required."); + } + if (!params.rowData || Object.keys(params.rowData).length === 0) { + throw new Error("Row data is required."); + } + + const result = await bridgeRequest("query.updateRow", { + dbId: params.dbId, + schemaName: params.schemaName, + tableName: params.tableName, + primaryKeyColumn: params.primaryKeyColumn, + primaryKeyValue: params.primaryKeyValue, + rowData: params.rowData, + }); + + return result?.result || result; + } catch (error: any) { + console.error("Failed to update row:", error); + throw new Error(`Failed to update row: ${error.message}`); + } + } + + /** + * Delete a row from a table + */ + async deleteRow(params: { + dbId: string; + schemaName: string; + tableName: string; + primaryKeyColumn: string; + primaryKeyValue: any; + }): Promise { + try { + if (!params.dbId || !params.schemaName || !params.tableName) { + throw new Error("Database ID, schema name, and table name are required."); + } + // Allow empty primaryKeyColumn if primaryKeyValue is an object (composite key) + if (!params.primaryKeyColumn && typeof params.primaryKeyValue !== 'object') { + throw new Error("Primary key column is required when not using composite key."); + } + if (params.primaryKeyValue === undefined || params.primaryKeyValue === null) { + throw new Error("Primary key value or row data is required."); + } + + const result = await bridgeRequest("query.deleteRow", { + dbId: params.dbId, + schemaName: params.schemaName, + tableName: params.tableName, + primaryKeyColumn: params.primaryKeyColumn, + primaryKeyValue: params.primaryKeyValue, + }); + + return result?.deleted === true; + } catch (error: any) { + console.error("Failed to delete row:", error); + throw new Error(`Failed to delete row: ${error.message}`); + } + } + + /** + * Search for rows in a table + */ + async searchTable(params: { + dbId: string; + schemaName: string; + tableName: string; + searchTerm: string; + column?: string; + page?: number; + pageSize?: number; + }): Promise<{ rows: any[]; total: number }> { + try { + if (!params.dbId || !params.schemaName || !params.tableName || !params.searchTerm) { + throw new Error("Database ID, schema name, table name, and search term are required."); + } + + const result = await bridgeRequest("query.searchTable", { + dbId: params.dbId, + schemaName: params.schemaName, + tableName: params.tableName, + searchTerm: params.searchTerm, + column: params.column, + page: params.page || 1, + pageSize: params.pageSize || 50, + }); + + return { rows: result?.rows || [], total: result?.total || 0 }; + } catch (error: any) { + console.error("Failed to search table:", error); + throw new Error(`Failed to search table: ${error.message}`); + } + } + + /** + * Discover locally running databases (on localhost or Docker) + * Scans common database ports and detects Docker containers + */ + async discoverDatabases(): Promise { + try { + const result = await bridgeRequest("db.discover", {}); + return result?.data || []; + } catch (error: any) { + console.error("Failed to discover databases:", error); + return []; // Return empty array on error, don't throw + } + } +} + + +export const databaseService = new DatabaseService(); \ No newline at end of file diff --git a/src/services/bridge/git.ts b/src/services/bridge/git.ts new file mode 100644 index 0000000..8d77d49 --- /dev/null +++ b/src/services/bridge/git.ts @@ -0,0 +1,201 @@ +import { GitBranchInfo, GitFileChange, GitLogEntry, GitPushPullResult, GitRemoteInfo, GitStatus } from "@/features/git/types"; +import { bridgeRequest } from "./bridgeClient"; + +class GitService { + /** + * Get git repository status for a directory + */ + async gitStatus(dir: string): Promise { + const result = await bridgeRequest("git.status", { dir }); + return result?.data; + } + + /** + * Initialize a new git repo in the given directory + */ + async gitInit(dir: string, defaultBranch = "main"): Promise { + const result = await bridgeRequest("git.init", { dir, defaultBranch }); + return result?.data; + } + + /** + * Get list of changed files + */ + async gitChanges(dir: string): Promise { + const result = await bridgeRequest("git.changes", { dir }); + return result?.data || []; + } + + /** + * Stage specific files + */ + async gitStage(dir: string, files: string[]): Promise { + await bridgeRequest("git.stage", { dir, files }); + } + + /** + * Stage all changes + */ + async gitStageAll(dir: string): Promise { + await bridgeRequest("git.stageAll", { dir }); + } + + /** + * Unstage specific files + */ + async gitUnstage(dir: string, files: string[]): Promise { + await bridgeRequest("git.unstage", { dir, files }); + } + + /** + * Commit staged changes + */ + async gitCommit(dir: string, message: string): Promise<{ hash: string }> { + const result = await bridgeRequest("git.commit", { dir, message }); + return result?.data; + } + + /** + * Get recent commit history + */ + async gitLog(dir: string, count = 20): Promise { + const result = await bridgeRequest("git.log", { dir, count }); + return result?.data || []; + } + + /** + * List all branches + */ + async gitBranches(dir: string): Promise { + const result = await bridgeRequest("git.branches", { dir }); + return result?.data || []; + } + + /** + * Create and checkout a new branch + */ + async gitCreateBranch(dir: string, name: string): Promise<{ branch: string }> { + const result = await bridgeRequest("git.createBranch", { dir, name }); + return result?.data; + } + + /** + * Checkout an existing branch + */ + async gitCheckout(dir: string, name: string): Promise<{ branch: string }> { + const result = await bridgeRequest("git.checkout", { dir, name }); + return result?.data; + } + + /** + * Discard unstaged changes for specific files + */ + async gitDiscard(dir: string, files: string[]): Promise { + await bridgeRequest("git.discard", { dir, files }); + } + + /** + * Stash all changes + */ + async gitStash(dir: string, message?: string): Promise { + await bridgeRequest("git.stash", { dir, message }); + } + + /** + * Pop latest stash + */ + async gitStashPop(dir: string): Promise { + await bridgeRequest("git.stashPop", { dir }); + } + + /** + * Get diff for a file (or all files) + */ + async gitDiff(dir: string, file?: string, staged = false): Promise { + const result = await bridgeRequest("git.diff", { dir, file, staged }); + return result?.data?.diff || ""; + } + + /** + * Ensure .gitignore has RelWave rules + */ + async gitEnsureIgnore(dir: string): Promise<{ modified: boolean }> { + const result = await bridgeRequest("git.ensureIgnore", { dir }); + return result?.data; + } + + /** List all configured remotes */ + async gitRemoteList(dir: string): Promise { + const result = await bridgeRequest("git.remoteList", { dir }); + return result?.data || []; + } + + /** Add a named remote */ + async gitRemoteAdd(dir: string, name: string, url: string): Promise { + await bridgeRequest("git.remoteAdd", { dir, name, url }); + } + + /** Remove a named remote */ + async gitRemoteRemove(dir: string, name: string): Promise { + await bridgeRequest("git.remoteRemove", { dir, name }); + } + + /** Get the URL of a remote */ + async gitRemoteGetUrl(dir: string, name = "origin"): Promise { + const result = await bridgeRequest("git.remoteGetUrl", { dir, name }); + return result?.data?.url || null; + } + + /** Change the URL of an existing remote */ + async gitRemoteSetUrl(dir: string, name: string, url: string): Promise { + await bridgeRequest("git.remoteSetUrl", { dir, name, url }); + } + + // ------------------------------------ + // 14. GIT PUSH / PULL / FETCH (P3) + // ------------------------------------ + + /** Push commits to a remote */ + async gitPush( + dir: string, + remote = "origin", + branch?: string, + options?: { force?: boolean; setUpstream?: boolean } + ): Promise { + const result = await bridgeRequest("git.push", { dir, remote, branch, ...options }); + return result?.data || { output: "" }; + } + + /** Pull from a remote */ + async gitPull( + dir: string, + remote = "origin", + branch?: string, + options?: { rebase?: boolean } + ): Promise { + const result = await bridgeRequest("git.pull", { dir, remote, branch, ...options }); + return result?.data || { output: "" }; + } + + /** Fetch from a remote (or all) */ + async gitFetch( + dir: string, + remote?: string, + options?: { prune?: boolean; all?: boolean } + ): Promise { + const result = await bridgeRequest("git.fetch", { dir, remote, ...options }); + return result?.data || { output: "" }; + } + + // ------------------------------------ + // 15. GIT REVERT (Rollback) + // ------------------------------------ + + /** Revert a specific commit */ + async gitRevert(dir: string, hash: string, noCommit = false): Promise { + const result = await bridgeRequest("git.revert", { dir, hash, noCommit }); + return result?.data || { output: "" }; + } +} + +export const gitService = new GitService(); \ No newline at end of file diff --git a/src/services/bridge/migration.ts b/src/services/bridge/migration.ts new file mode 100644 index 0000000..ea818f2 --- /dev/null +++ b/src/services/bridge/migration.ts @@ -0,0 +1,113 @@ +import { bridgeRequest } from "./bridgeClient"; + +class MigrationService { + /** + * Generate CREATE TABLE migration file + */ + async generateCreateMigration(params: { + dbId: string; + schemaName: string; + tableName: string; + columns: any[]; + foreignKeys?: any[]; + }): Promise<{ version: string; filename: string; filepath: string }> { + try { + const result = await bridgeRequest("migration.generateCreate", params); + return result?.data; + } catch (error: any) { + console.error("Failed to generate create migration:", error); + throw new Error(`Failed to generate migration: ${error.message}`); + } + } + + /** + * Generate ALTER TABLE migration file + */ + async generateAlterMigration(params: { + dbId: string; + schemaName: string; + tableName: string; + operations: any[]; + }): Promise<{ version: string; filename: string; filepath: string }> { + try { + const result = await bridgeRequest("migration.generateAlter", params); + return result?.data; + } catch (error: any) { + console.error("Failed to generate alter migration:", error); + throw new Error(`Failed to generate migration: ${error.message}`); + } + } + + /** + * Generate DROP TABLE migration file + */ + async generateDropMigration(params: { + dbId: string; + schemaName: string; + tableName: string; + mode?: "RESTRICT" | "DETACH_FKS" | "CASCADE"; + }): Promise<{ version: string; filename: string; filepath: string }> { + try { + const result = await bridgeRequest("migration.generateDrop", params); + return result?.data; + } catch (error: any) { + console.error("Failed to generate drop migration:", error); + throw new Error(`Failed to generate migration: ${error.message}`); + } + } + + /** + * Apply a pending migration + */ + async applyMigration(dbId: string, version: string): Promise { + try { + const result = await bridgeRequest("migration.apply", { dbId, version }); + return result?.ok === true; + } catch (error: any) { + console.error("Failed to apply migration:", error); + throw new Error(`Failed to apply migration: ${error.message}`); + } + } + + /** + * Rollback an applied migration + */ + async rollbackMigration(dbId: string, version: string): Promise { + try { + const result = await bridgeRequest("migration.rollback", { dbId, version }); + return result?.ok === true; + } catch (error: any) { + console.error("Failed to rollback migration:", error); + throw new Error(`Failed to rollback migration: ${error.message}`); + } + } + + /** + * Delete a pending migration file + */ + async deleteMigration(dbId: string, version: string): Promise { + try { + const result = await bridgeRequest("migration.delete", { dbId, version }); + return result?.ok === true; + } catch (error: any) { + console.error("Failed to delete migration:", error); + throw new Error(`Failed to delete migration: ${error.message}`); + } + } + + /** + * Get migration SQL (up and down) + */ + async getMigrationSQL(dbId: string, version: string): Promise<{ up: string; down: string }> { + try { + const result = await bridgeRequest("migration.getSQL", { dbId, version }); + return result?.data; + } catch (error: any) { + console.error("Failed to get migration SQL:", error); + throw new Error(`Failed to get migration SQL: ${error.message}`); + } + } +} + + +export const migrationService = new MigrationService(); \ No newline at end of file diff --git a/src/services/bridge/project.ts b/src/services/bridge/project.ts new file mode 100644 index 0000000..e80014c --- /dev/null +++ b/src/services/bridge/project.ts @@ -0,0 +1,349 @@ +import { + ProjectSummary, + ProjectMetadata, + CreateProjectParams, + UpdateProjectParams, + SchemaFile, + SchemaSnapshot, + ERDiagramFile, + ERNode, + QueriesFile, + SavedQuery, + ProjectExport, + ImportProjectParams, + ScanImportResult, + AnnotationsFile +} from "@/features/project/types"; import { bridgeRequest } from "./bridgeClient"; +import { TLEditorSnapshot } from "tldraw"; + +class ProjectService { + // ------------------------------------ + // 6. PROJECT METHODS (project.*) + // ------------------------------------ + + /** + * List all projects + */ + async listProjects(): Promise { + try { + const result = await bridgeRequest("project.list", {}); + return result?.data || []; + } catch (error: any) { + console.error("Failed to list projects:", error); + throw new Error(`Failed to list projects: ${error.message}`); + } + } + + /** + * Get a single project by ID + */ + async getProject(projectId: string): Promise { + try { + if (!projectId) throw new Error("Project ID is required"); + const result = await bridgeRequest("project.get", { id: projectId }); + return result?.data || null; + } catch (error: any) { + console.error("Failed to get project:", error); + throw new Error(`Failed to get project: ${error.message}`); + } + } + + /** + * Find a project linked to a specific database connection. + * Returns null when no project is linked (not an error). + */ + async getProjectByDatabaseId(databaseId: string): Promise { + try { + if (!databaseId) throw new Error("Database ID is required"); + const result = await bridgeRequest("project.getByDatabaseId", { databaseId }); + return result?.data || null; + } catch (error: any) { + console.error("Failed to get project by database ID:", error); + throw new Error(`Failed to get project by database ID: ${error.message}`); + } + } + + /** + * Create a new project linked to a database connection + */ + async createProject(params: CreateProjectParams): Promise { + try { + if (!params.databaseId || !params.name) { + throw new Error("databaseId and name are required"); + } + const result = await bridgeRequest("project.create", params); + if (!result?.data) throw new Error("Failed to create project"); + return result.data; + } catch (error: any) { + console.error("Failed to create project:", error); + throw new Error(`Failed to create project: ${error.message}`); + } + } + + /** + * Update a project's metadata + */ + async updateProject(params: UpdateProjectParams): Promise { + try { + if (!params.id) throw new Error("Project ID is required"); + const result = await bridgeRequest("project.update", params); + if (!result?.data) throw new Error("Project not found"); + return result.data; + } catch (error: any) { + console.error("Failed to update project:", error); + throw new Error(`Failed to update project: ${error.message}`); + } + } + + /** + * Delete a project and all its files + */ + async deleteProject(projectId: string): Promise { + try { + if (!projectId) throw new Error("Project ID is required"); + await bridgeRequest("project.delete", { id: projectId }); + } catch (error: any) { + console.error("Failed to delete project:", error); + throw new Error(`Failed to delete project: ${error.message}`); + } + } + + /** + * Get cached schema for a project + */ + async getProjectSchema(projectId: string): Promise { + try { + if (!projectId) throw new Error("Project ID is required"); + const result = await bridgeRequest("project.getSchema", { projectId }); + return result?.data || null; + } catch (error: any) { + console.error("Failed to get project schema:", error); + throw new Error(`Failed to get project schema: ${error.message}`); + } + } + + /** + * Save/cache schema data for a project + */ + async saveProjectSchema(projectId: string, schemas: SchemaSnapshot[]): Promise { + try { + if (!projectId || !schemas) throw new Error("projectId and schemas are required"); + const result = await bridgeRequest("project.saveSchema", { projectId, schemas }); + return result?.data; + } catch (error: any) { + console.error("Failed to save project schema:", error); + throw new Error(`Failed to save project schema: ${error.message}`); + } + } + + /** + * Get ER diagram layout for a project + */ + async getProjectERDiagram(projectId: string): Promise { + try { + if (!projectId) throw new Error("Project ID is required"); + const result = await bridgeRequest("project.getERDiagram", { projectId }); + return result?.data || null; + } catch (error: any) { + console.error("Failed to get ER diagram:", error); + throw new Error(`Failed to get ER diagram: ${error.message}`); + } + } + + /** + * Save ER diagram layout for a project + */ + async saveProjectERDiagram( + projectId: string, + data: { nodes: ERNode[]; zoom?: number; panX?: number; panY?: number } + ): Promise { + try { + if (!projectId || !data.nodes) throw new Error("projectId and nodes are required"); + const result = await bridgeRequest("project.saveERDiagram", { projectId, ...data }); + return result?.data; + } catch (error: any) { + console.error("Failed to save ER diagram:", error); + throw new Error(`Failed to save ER diagram: ${error.message}`); + } + } + + /** + * Get annotations for a project + */ + async getProjectAnnotations(projectId: string): Promise { + try { + if (!projectId) throw new Error("Project ID is required"); + const result = await bridgeRequest("project.getAnnotations", { projectId }); + return result?.data || null; + } catch (error: any) { + console.error("Failed to get annotations:", error); + throw new Error(`Failed to get annotations: ${error.message}`); + } + } + + /** + * Save annotations for a project + */ + async saveProjectAnnotations( + projectId: string, + snapshot: TLEditorSnapshot + ): Promise { + try { + if (!projectId) throw new Error("Project ID is required"); + if (!snapshot || typeof snapshot !== "object" || Object.keys(snapshot).length === 0) { + throw new Error("A non-empty snapshot object is required"); + } + const result = await bridgeRequest("project.saveAnnotations", { projectId, snapshot }); + return result?.data; + } catch (error: any) { + console.error("Failed to save annotations:", error); + throw new Error(`Failed to save annotations: ${error.message}`); + } + } + + /** + * Get saved queries for a project + */ + async getProjectQueries(projectId: string): Promise { + try { + if (!projectId) throw new Error("Project ID is required"); + const result = await bridgeRequest("project.getQueries", { projectId }); + return result?.data || null; + } catch (error: any) { + console.error("Failed to get project queries:", error); + throw new Error(`Failed to get project queries: ${error.message}`); + } + } + + /** + * Add a saved query to a project + */ + async addProjectQuery( + projectId: string, + params: { name: string; sql: string; description?: string } + ): Promise { + try { + if (!projectId || !params.name || !params.sql) { + throw new Error("projectId, name, and sql are required"); + } + const result = await bridgeRequest("project.addQuery", { projectId, ...params }); + return result?.data; + } catch (error: any) { + console.error("Failed to add project query:", error); + throw new Error(`Failed to add project query: ${error.message}`); + } + } + + /** + * Update a saved query in a project + */ + async updateProjectQuery( + projectId: string, + queryId: string, + updates: { name?: string; sql?: string; description?: string } + ): Promise { + try { + if (!projectId || !queryId) throw new Error("projectId and queryId are required"); + const result = await bridgeRequest("project.updateQuery", { projectId, queryId, ...updates }); + if (!result?.data) throw new Error("Query not found"); + return result.data; + } catch (error: any) { + console.error("Failed to update project query:", error); + throw new Error(`Failed to update project query: ${error.message}`); + } + } + + /** + * Delete a saved query from a project + */ + async deleteProjectQuery(projectId: string, queryId: string): Promise { + try { + if (!projectId || !queryId) throw new Error("projectId and queryId are required"); + await bridgeRequest("project.deleteQuery", { projectId, queryId }); + } catch (error: any) { + console.error("Failed to delete project query:", error); + throw new Error(`Failed to delete project query: ${error.message}`); + } + } + + /** + * Export full project bundle (metadata + schema + ER + queries) + */ + async exportProject(projectId: string): Promise { + try { + if (!projectId) throw new Error("Project ID is required"); + const result = await bridgeRequest("project.export", { projectId }); + return result?.data || null; + } catch (error: any) { + console.error("Failed to export project:", error); + throw new Error(`Failed to export project: ${error.message}`); + } + } + + /** + * Get the filesystem directory path for a project + */ + async getProjectDir(projectId: string): Promise { + try { + if (!projectId) return null; + const result = await bridgeRequest("project.getDir", { projectId }); + return result?.data?.dir || null; + } catch (error: any) { + console.error("Failed to get project dir:", error); + return null; + } + } + + /** + * Scan a cloned repo directory for import — read-only, no side effects. + * Returns project metadata and .env info so the UI can preview. + */ + async scanImportSource(sourcePath: string): Promise { + try { + if (!sourcePath) throw new Error("sourcePath is required"); + const result = await bridgeRequest("project.scanImport", { sourcePath }); + if (!result?.data) throw new Error("Failed to scan import source"); + return result.data; + } catch (error: any) { + console.error("Failed to scan import source:", error); + throw new Error(`Failed to scan import source: ${error.message}`); + } + } + + /** + * Import a project from a cloned repository directory. + * Requires a valid databaseId — create the database connection first. + */ + async importProject(params: ImportProjectParams): Promise { + try { + if (!params.sourcePath) throw new Error("sourcePath is required"); + if (!params.databaseId) throw new Error("databaseId is required"); + const result = await bridgeRequest("project.import", params); + if (!result?.data) throw new Error("Failed to import project"); + return result.data; + } catch (error: any) { + console.error("Failed to import project:", error); + throw new Error(`Failed to import project: ${error.message}`); + } + } + + /** + * Link (or re-link) a database connection to a project. + * Updates the project's databaseId and resolves the engine type. + */ + async linkProjectDatabase(projectId: string, databaseId: string): Promise { + try { + if (!projectId || !databaseId) { + throw new Error("projectId and databaseId are required"); + } + const result = await bridgeRequest("project.linkDatabase", { projectId, databaseId }); + if (!result?.data) throw new Error("Project not found"); + return result.data; + } catch (error: any) { + console.error("Failed to link database:", error); + throw new Error(`Failed to link database: ${error.message}`); + } + } +} + +export const projectService = new ProjectService(); \ No newline at end of file diff --git a/src/services/bridge/query.ts b/src/services/bridge/query.ts new file mode 100644 index 0000000..77530ad --- /dev/null +++ b/src/services/bridge/query.ts @@ -0,0 +1,64 @@ +import { RunQueryParams, TableRow } from "@/features/database/types"; +import { bridgeRequest } from "./bridgeClient"; + +class QueryService { + /** + * Executes a streaming/long-running SQL query. + * The actual results, progress, and completion status are sent via asynchronous notifications + * (query.started, query.result, query.done, query.error) handled by bridgeClient listeners. + * @param params - Contains sessionId, dbId, SQL query, and optional batchSize. + * @returns Promise resolves when the query is successfully *initiated* on the server. + */ + async runQuery(params: RunQueryParams): Promise { + try { + if (!params.sessionId || !params.dbId || !params.sql) { + throw new Error("sessionId, dbId, and sql are required."); + } + + // The server returns immediately after starting the background job. + await bridgeRequest("query.run", params); + } catch (error: any) { + console.error("Failed to initiate query execution:", error); + throw new Error(`Failed to run query: ${error.message}`); + } + } + + /** + * Fetches data from a specific table with pagination support. + * @param dbId - The ID of the database connection to use. + * @param schemaName - The schema containing the table (e.g., 'public'). + * @param tableName - The name of the table. + * @param limit - Number of rows per page. + * @param page - Page number (1-based). + * @returns Object with rows array and totalCount. + */ + async fetchTableData( + dbId: string, + schemaName: string, + tableName: string, + limit: number, + page: number + ): Promise<{ rows: TableRow[]; total: number }> { + try { + if (!dbId || !schemaName || !tableName) { + throw new Error("Database ID, schema, and table name are required."); + } + const result = await bridgeRequest("query.fetchTableData", { + dbId, + schemaName, + tableName, + limit, + page + }); + return { + rows: result?.data?.rows || [], + total: result?.data?.total || result?.data?.rows?.length || 0 + }; + } catch (error: any) { + console.error("Failed to fetch table data:", error); + throw new Error(`Failed to fetch table data: ${error.message}`); + } + } +} + +export const queryService = new QueryService(); \ No newline at end of file diff --git a/src/services/bridge/session.ts b/src/services/bridge/session.ts new file mode 100644 index 0000000..3e13cb0 --- /dev/null +++ b/src/services/bridge/session.ts @@ -0,0 +1,46 @@ +import { bridgeRequest } from "./bridgeClient"; + +class SessionService { + /** + * Creates a new query session on the bridge server. + * @param connectionConfig - (Optional) Connection details if needed for session meta. + * @returns The unique sessionId string. + */ + async createSession(connectionConfig?: any): Promise { + try { + const result = await bridgeRequest("query.createSession", { + config: connectionConfig, + }); + const sessionId = result?.data?.sessionId; + if (!sessionId) { + throw new Error("Server failed to return a session ID."); + } + return sessionId; + } catch (error: any) { + console.error("Failed to create query session:", error); + throw new Error(`Failed to create query session: ${error.message}`); + } + } + + /** + * Cancels an active query session on the bridge server. + * @param sessionId - The ID of the session to cancel. + * @returns true if the query was successfully cancelled or false if it was not running. + */ + async cancelSession(sessionId: string): Promise { + try { + if (!sessionId) { + throw new Error("Session ID is required for cancellation."); + } + const result = await bridgeRequest("query.cancel", { sessionId }); + return result?.data?.cancelled === true; + } catch (error: any) { + console.error("Failed to cancel session:", error); + throw new Error(`Failed to cancel session: ${error.message}`); + } + } + +} + + +export const sessionService = new SessionService(); \ No newline at end of file diff --git a/src/hooks/useBridgeInit.ts b/src/services/bridge/useBridgeInit.ts similarity index 97% rename from src/hooks/useBridgeInit.ts rename to src/services/bridge/useBridgeInit.ts index 6ac2924..92284ca 100644 --- a/src/hooks/useBridgeInit.ts +++ b/src/services/bridge/useBridgeInit.ts @@ -5,7 +5,7 @@ import { stopBridgeListeners, isBridgeReady, waitForTauri -} from "@/services/bridgeClient"; +} from "@/services/bridge/bridgeClient"; /** * Initializes the Tauri bridge once the app is ready. diff --git a/src/hooks/useBridgeQuery.ts b/src/services/bridge/useBridgeQuery.ts similarity index 94% rename from src/hooks/useBridgeQuery.ts rename to src/services/bridge/useBridgeQuery.ts index 3f4c001..e344005 100644 --- a/src/hooks/useBridgeQuery.ts +++ b/src/services/bridge/useBridgeQuery.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { startBridgeListeners, isBridgeReady } from "@/services/bridgeClient"; +import { startBridgeListeners, isBridgeReady } from "@/services/bridge/bridgeClient"; export function useBridgeQuery() { return useQuery({ diff --git a/src/services/bridgeApi.ts b/src/services/bridgeApi.ts deleted file mode 100644 index 9de8eb9..0000000 --- a/src/services/bridgeApi.ts +++ /dev/null @@ -1,1343 +0,0 @@ -import { AddDatabaseParams, ConnectionTestResult, CreateTableColumn, DatabaseConnection, DatabaseSchemaDetails, DatabaseStats, DiscoveredDatabase, RunQueryParams, TableRow, UpdateDatabaseParams } from "@/types/database"; -import { ProjectSummary, ProjectMetadata, CreateProjectParams, UpdateProjectParams, SchemaFile, SchemaSnapshot, ERDiagramFile, ERNode, QueriesFile, SavedQuery, ProjectExport, ImportProjectParams, ScanImportResult, AnnotationsFile } from "@/types/project"; -import type { TLEditorSnapshot } from "tldraw"; -import { GitStatus, GitFileChange, GitLogEntry, GitBranchInfo, GitRemoteInfo, GitPushPullResult } from "@/types/git"; -import { bridgeRequest } from "./bridgeClient"; - - -class BridgeApiService { - // ------------------------------------ - // 1. SESSION MANAGEMENT METHODS (query.*) - // ------------------------------------ - - /** - * Creates a new query session on the bridge server. - * @param connectionConfig - (Optional) Connection details if needed for session meta. - * @returns The unique sessionId string. - */ - async createSession(connectionConfig?: any): Promise { - try { - const result = await bridgeRequest("query.createSession", { - config: connectionConfig, - }); - const sessionId = result?.data?.sessionId; - if (!sessionId) { - throw new Error("Server failed to return a session ID."); - } - return sessionId; - } catch (error: any) { - console.error("Failed to create query session:", error); - throw new Error(`Failed to create query session: ${error.message}`); - } - } - - /** - * Cancels an active query session on the bridge server. - * @param sessionId - The ID of the session to cancel. - * @returns true if the query was successfully cancelled or false if it was not running. - */ - async cancelSession(sessionId: string): Promise { - try { - if (!sessionId) { - throw new Error("Session ID is required for cancellation."); - } - const result = await bridgeRequest("query.cancel", { sessionId }); - return result?.data?.cancelled === true; - } catch (error: any) { - console.error("Failed to cancel session:", error); - throw new Error(`Failed to cancel session: ${error.message}`); - } - } - - // ------------------------------------ - // 2. DATA RETRIEVAL METHODS (query.*) - // ------------------------------------ - - /** - * Executes a streaming/long-running SQL query. - * The actual results, progress, and completion status are sent via asynchronous notifications - * (query.started, query.result, query.done, query.error) handled by bridgeClient listeners. - * @param params - Contains sessionId, dbId, SQL query, and optional batchSize. - * @returns Promise resolves when the query is successfully *initiated* on the server. - */ - async runQuery(params: RunQueryParams): Promise { - try { - if (!params.sessionId || !params.dbId || !params.sql) { - throw new Error("sessionId, dbId, and sql are required."); - } - - // The server returns immediately after starting the background job. - await bridgeRequest("query.run", params); - } catch (error: any) { - console.error("Failed to initiate query execution:", error); - throw new Error(`Failed to run query: ${error.message}`); - } - } - - /** - * Fetches data from a specific table with pagination support. - * @param dbId - The ID of the database connection to use. - * @param schemaName - The schema containing the table (e.g., 'public'). - * @param tableName - The name of the table. - * @param limit - Number of rows per page. - * @param page - Page number (1-based). - * @returns Object with rows array and totalCount. - */ - async fetchTableData( - dbId: string, - schemaName: string, - tableName: string, - limit: number, - page: number - ): Promise<{ rows: TableRow[]; total: number }> { - try { - if (!dbId || !schemaName || !tableName) { - throw new Error("Database ID, schema, and table name are required."); - } - const result = await bridgeRequest("query.fetchTableData", { - dbId, - schemaName, - tableName, - limit, - page - }); - return { - rows: result?.data?.rows || [], - total: result?.data?.total || result?.data?.rows?.length || 0 - }; - } catch (error: any) { - console.error("Failed to fetch table data:", error); - throw new Error(`Failed to fetch table data: ${error.message}`); - } - } - - // ------------------------------------ - // 3. DATABASE CRUD/METADATA METHODS (db.*) - // ------------------------------------ - - /** - * List all database connections - */ - async listDatabases(): Promise { - try { - const result = await bridgeRequest("db.list", {}); - return result?.data || []; - } catch (error: any) { - console.error("Failed to list databases:", error); - throw new Error(`Failed to list databases: ${error.message}`); - } - } - - /** - * Get a specific database connection by ID - */ - async getDatabase(id: string): Promise { - try { - const result = await bridgeRequest("db.get", { id }); - return result?.data || null; - } catch (error: any) { - console.error("Failed to get database:", error); - throw new Error(`Failed to get database: ${error.message}`); - } - } - - /** - * Get migrations data for a database - */ - async getMigrations(id: string): Promise<{ migrations: { local: any[]; applied: any[] }; baselined: boolean } | null> { - try { - const result = await bridgeRequest("query.connectToDatabase", { dbId: id }); - console.log(result) - return result?.result || null; - } catch (error: any) { - console.error("Failed to get migrations:", error); - throw new Error(`Failed to get migrations: ${error.message}`); - } - } - - /** - * Add a new database connection - */ - async addDatabase(params: AddDatabaseParams): Promise { - try { - // Validate required fields - const isSQLite = params.type === "sqlite"; - const required = isSQLite - ? ["name", "type", "database"] - : ["name", "type", "host", "port", "user", "database"]; - for (const field of required) { - if (!params[field as keyof AddDatabaseParams]) { - throw new Error(`Missing required field: ${field}`); - } - } - const result = await bridgeRequest("db.add", params); - if (!result?.ok) { - throw new Error("Failed to add database"); - } - - // Fetch the full database details - const dbId = result.data?.id; - if (!dbId) { - throw new Error("No database ID returned"); - } - - const database = await this.getDatabase(dbId); - if (!database) { - throw new Error("Failed to fetch created database"); - } - - return database; - } catch (error: any) { - console.error("Failed to add database:", error); - throw new Error(`Failed to add database: ${error.message}`); - } - } - - /** - * Update an existing database connection - */ - async updateDatabase(params: UpdateDatabaseParams): Promise { - try { - if (!params.id) { - throw new Error("Database ID is required"); - } - - await bridgeRequest("db.update", params); - } catch (error: any) { - console.error("Failed to update database:", error); - throw new Error(`Failed to update database: ${error.message}`); - } - } - - /** - * Delete a database connection - */ - async deleteDatabase(id: string): Promise { - try { - if (!id) { - throw new Error("Database ID is required"); - } - - await bridgeRequest("db.delete", { id }); - } catch (error: any) { - console.error("Failed to delete database:", error); - throw new Error(`Failed to delete database: ${error.message}`); - } - } - - /** - * Update the lastAccessedAt timestamp for a database - * @param id - Database ID to touch - */ - async touchDatabase(id: string): Promise { - try { - if (!id) return; - await bridgeRequest("db.touch", { id }); - } catch (error: any) { - // Silently fail - this is not critical - console.warn("Failed to update last accessed time:", error); - } - } - - /** - * Test connection to a database - * @param id - Database ID to test - */ - async testConnection(id: string): Promise { - try { - if (!id) { - throw new Error("Database ID is required"); - } - - const result = await bridgeRequest("db.connectTest", { id }); - console.log(result); - return result?.data || { ok: false, message: "Unknown error" }; - } catch (error: any) { - console.error("Failed to test connection:", error); - return { ok: false, message: error.message, status: 'disconnected' }; - } - } - - async testAllConnections(): Promise<{ id: string; result: ConnectionTestResult }[]> { - try { - const databases = await this.listDatabases(); - const results: { id: string; result: ConnectionTestResult }[] = []; - for (const db of databases) { - const testResult = await this.testConnection(db.id); - results.push({ id: db.id, result: testResult }); - } - return results; - } catch (error) { - console.log(error) - return []; - } - } - - /** - * Test connection with raw connection parameters (without saving) - */ - async testConnectionDirect(connection: { - host: string; - port: number; - user: string; - password?: string; - database: string; - }): Promise { - try { - const result = await bridgeRequest("db.connectTest", { connection }); - return result?.data || { ok: false, message: "Unknown error" }; - } catch (error: any) { - console.error("Failed to test connection:", error); - return { ok: false, message: error.message, status: 'disconnected' }; - } - } - - /** - * List all tables in a database - */ - async listTables(id: string, schema?: string): Promise { - // Changed return type to any[] to match typical result shape [{schema, name, type}] - try { - if (!id) { - throw new Error("Database ID is required"); - } - - const result = await bridgeRequest("db.listTables", { id, schema }); - return result?.data || []; - } catch (error: any) { - console.error("Failed to list tables:", error); - throw new Error(`Failed to list tables: ${error.message}`); - } - } - - async listSchemas(id: string): Promise { - try { - if (!id) { - throw new Error("Database ID is required"); - } - - const result = await bridgeRequest("db.listSchemas", { id }); - return result?.data || []; - } catch (error: any) { - console.error("Failed to list schemas:", error); - throw new Error(`Failed to list schemas: ${error.message}`); - } - } - - /** - * Alias for getDatabaseStats - used by useDbQueries hook - */ - async getDataBaseStats(id: string): Promise { - try { - if (!id) { - throw new Error("Database ID is required"); - } - const result = await bridgeRequest("db.getStats", { id }); - return result?.data || { tables: 0, rows: 0, sizeBytes: 0 }; - } catch (error: any) { - console.error("Failed to get database stats:", error); - throw new Error(`Failed to get database stats: ${error.message}`); - } - } - - async getTotalDatabaseStats(): Promise { - try { - const result = await bridgeRequest("db.getTotalStats", {}); - return result?.data || { row: 0, size: 0, tables: 0 }; - } catch (error) { - console.log(error); - throw new Error(`Failed to get total database stats: ${error}`); - } - } - - async getSchema(id: string): Promise { - try { - if (!id) { - throw new Error("Database ID is required."); - } - const result = await bridgeRequest("db.getSchema", { id }); - return result?.data || null; - } catch (error: any) { - console.error("Failed to fetch schema details:", error); - throw new Error(`Failed to fetch schema details: ${error.message}`); - } - } - - async getPrimaryKeys(id: string, schemaName: string, tableName: string): Promise { - try { - if (!id || !schemaName || !tableName) { - throw new Error("Database ID, schema name, and table name are required."); - } - const result = await bridgeRequest("query.listPrimaryKeys", { - dbId: id, - schemaName, - tableName, - }); - return result?.primaryKeys[0].column_name || result?.primaryKeys[0].COLUMN_NAME || result.primaryKeys[0] || ""; - } catch (error: any) { - console.error("Failed to fetch primary keys:", error); - throw new Error(`Failed to fetch primary keys: ${error.message}`); - } - } - - /** - * Create a new table in the database - */ - async createTable(params: { - dbId: string; - schemaName: string; - tableName: string; - columns: CreateTableColumn[]; - foreignKeys?: any[]; - }): Promise { - try { - if (!params.dbId || !params.schemaName || !params.tableName) { - throw new Error("Database ID, schema name, and table name are required."); - } - if (!params.columns || params.columns.length === 0) { - throw new Error("At least one column is required."); - } - const result = await bridgeRequest("query.createTable", { - dbId: params.dbId, - schemaName: params.schemaName, - tableName: params.tableName, - columns: params.columns, - foreignKeys: params.foreignKeys || [], - }); - return result?.ok === true; - } catch (error: any) { - console.error("Failed to create table:", error); - throw new Error(`Failed to create table: ${error.message}`); - } - } - - /** - * Create indexes for tables in the database - */ - async createIndexes(params: { - dbId: string; - schemaName: string; - indexes: any[]; - }): Promise { - try { - if (!params.dbId || !params.schemaName) { - throw new Error("Database ID and schema name are required."); - } - if (!params.indexes || params.indexes.length === 0) { - throw new Error("At least one index is required."); - } - - const result = await bridgeRequest("query.createIndexes", { - dbId: params.dbId, - schemaName: params.schemaName, - indexes: params.indexes, - }); - console.log(result) - return result?.ok === true; - } catch (error: any) { - console.error("Failed to create indexes:", error); - throw new Error(`Failed to create indexes: ${error.message}`); - } - } - - /** - * Alter table structure - */ - async alterTable(params: { - dbId: string; - schemaName: string; - tableName: string; - operations: any[]; - }): Promise { - try { - if (!params.dbId || !params.schemaName || !params.tableName) { - throw new Error("Database ID, schema name, and table name are required."); - } - if (!params.operations || params.operations.length === 0) { - throw new Error("At least one operation is required."); - } - - const result = await bridgeRequest("query.alterTable", { - dbId: params.dbId, - schemaName: params.schemaName, - tableName: params.tableName, - operations: params.operations, - }); - - return result?.ok === true; - } catch (error: any) { - console.error("Failed to alter table:", error); - throw new Error(`Failed to alter table: ${error.message}`); - } - } - - /** - * Drop a table - */ - async dropTable(params: { - dbId: string; - schemaName: string; - tableName: string; - mode?: "RESTRICT" | "DETACH_FKS" | "CASCADE"; - }): Promise { - try { - if (!params.dbId || !params.schemaName || !params.tableName) { - throw new Error("Database ID, schema name, and table name are required."); - } - - const result = await bridgeRequest("query.dropTable", { - dbId: params.dbId, - schemaName: params.schemaName, - tableName: params.tableName, - mode: params.mode || "RESTRICT", - }); - - return result?.ok === true; - } catch (error: any) { - console.error("Failed to drop table:", error); - throw new Error(`Failed to drop table: ${error.message}`); - } - } - - /** - * Insert a row into a table - */ - async insertRow(params: { - dbId: string; - schemaName: string; - tableName: string; - rowData: Record; - }): Promise { - try { - if (!params.dbId || !params.schemaName || !params.tableName) { - throw new Error("Database ID, schema name, and table name are required."); - } - if (!params.rowData || Object.keys(params.rowData).length === 0) { - throw new Error("Row data is required."); - } - - const result = await bridgeRequest("query.insertRow", { - dbId: params.dbId, - schemaName: params.schemaName, - tableName: params.tableName, - rowData: params.rowData, - }); - - return result?.result || result; - } catch (error: any) { - console.error("Failed to insert row:", error); - throw new Error(`Failed to insert row: ${error.message}`); - } - } - - /** - * Update a row in a table - */ - async updateRow(params: { - dbId: string; - schemaName: string; - tableName: string; - primaryKeyColumn: string; - primaryKeyValue: any; - rowData: Record; - }): Promise { - try { - if (!params.dbId || !params.schemaName || !params.tableName || !params.primaryKeyColumn) { - throw new Error("Database ID, schema name, table name, and primary key column are required."); - } - if (params.primaryKeyValue === undefined) { - throw new Error("Primary key value is required."); - } - if (!params.rowData || Object.keys(params.rowData).length === 0) { - throw new Error("Row data is required."); - } - - const result = await bridgeRequest("query.updateRow", { - dbId: params.dbId, - schemaName: params.schemaName, - tableName: params.tableName, - primaryKeyColumn: params.primaryKeyColumn, - primaryKeyValue: params.primaryKeyValue, - rowData: params.rowData, - }); - - return result?.result || result; - } catch (error: any) { - console.error("Failed to update row:", error); - throw new Error(`Failed to update row: ${error.message}`); - } - } - - /** - * Delete a row from a table - */ - async deleteRow(params: { - dbId: string; - schemaName: string; - tableName: string; - primaryKeyColumn: string; - primaryKeyValue: any; - }): Promise
Processing data...
{errorMessage}
Select axes to visualize data
No results found
+
This table is empty or the query returned no data
+ {tables.length} tables in {selectedSchema} +
+ Failed to connect to the database: +
+ {error} +
Select a connection or add a new one
+ Save database details, ER diagrams & queries offline +
+ Check for new versions of RelWave +
You're running the latest version.
Update checks are disabled in development mode.
{updateError}
v{updateInfo.version} available
{updateInfo.body}
Update downloaded. Restart to apply.
+ Select your preferred color theme +
+ Enable developer tools and context menu options +
Developer features enabled:
+ Customize your app appearance +
+ This is a preview of how text and UI elements will look with your selected theme. +
+ Choose between light and dark mode +
+ RelWave v{appVersion || "—"} +
- Failed to connect to the database: -
- {error} -
- {tables.length} tables in {selectedSchema} -
- Save database details, ER diagrams & queries offline -
- Customize your app appearance -
- Choose between light and dark mode -
- Select your preferred color theme -
- Enable developer tools and context menu options -
- Check for new versions of RelWave -
- RelWave v{appVersion || "—"} -
- This is a preview of how text and UI elements will look with your selected theme. -