import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react"; import { ChevronDown, ChevronUp, Table2, Terminal, AlertCircle, Database } from "lucide-react"; import { format as formatSql } from "sql-formatter"; import { TooltipProvider } from "../ui/Tooltip"; import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar"; import { DbViewerToolbar } from "./DbViewerToolbar"; import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils"; import { executeQuery, cancelQuery } from "../../lib/commands"; import { getCapabilities } from "../../lib/dbCapabilities"; const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor }))); import { QueryToolbar } from "../editor/QueryToolbar"; const DestructiveQueryDialog = lazy(() => import("../editor/DestructiveQueryDialog").then((m) => ({ default: m.DestructiveQueryDialog })), ); import { TableTree } from "./TableTree"; import { ObjectExplorerPage } from "./ObjectExplorerPage"; import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail"; import { ObjectFormTab } from "./objects/ObjectFormTab"; import { TabBar } from "./TabBar"; import { VirtualDataGrid } from "../grid/VirtualDataGrid"; import { RowDetailDrawer } from "../grid/RowDetailDrawer"; import { TableControls } from "./TableControls"; import { EditConnectionModal } from "./EditConnectionModal"; import { PasswordPromptDialog } from "./PasswordPromptDialog"; import { useDbConnection } from "../../hooks/useDbConnection"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useConnectionStore } from "../../stores/connectionStore"; import { useSettingsStore } from "../../stores/settingsStore"; import { useNotificationStore } from "../../stores/notificationStore"; import { useShortcut } from "../../hooks/useShortcut"; import { ConnectionDropBanner } from "./ConnectionDropBanner"; import { ToolsPage } from "./ToolsPage"; import { SchemaVisualizerPage } from "./SchemaVisualizerPage"; import { QueriesPanel } from "../queries/QueriesPanel"; import { useQueryStore } from "../../stores/queryStore"; import { ObjectSearchPalette } from "./ObjectSearchPalette"; import * as cmd from "../../lib/commands"; import type { EnumInfo } from "../../lib/types"; import type { FkOption } from "../grid/CellEditor"; import type { QueueItem } from "../../stores/dbViewerStore"; /** * Derive optimistic staged cell values from the changes queue for a table * tab, keyed `${rowIndex}:${colName}` → the staged value (null = NULL). * Rows are matched to queue items via the row locator (PK or ctid/rowid). * Pending + committed updates count (survive until refetch); Clear All * empties the queue so the optimistic display vanishes. */ export function deriveStagedValues( changesQueue: QueueItem[], schema: string, table: string, rows: unknown[][], getLocator: (row: unknown[]) => Record, ): Record { const map: Record = {}; const updates = changesQueue.filter( (c) => c.type === "update" && (c.status === "pending" || c.status === "committed") && c.schema === schema && c.table === table && c.primaryKey && c.newData, ); if (updates.length === 0) return map; rows.forEach((row, rowIdx) => { const loc = getLocator(row); for (const c of updates) { const pk = c.primaryKey!; const matches = Object.entries(pk).every( ([k, v]) => String(loc[k]) === String(v), ); if (!matches) continue; const colName = Object.keys(c.newData!)[0]; if (!colName) continue; map[`${rowIdx}:${colName}`] = (c.newData![colName] as string | null) ?? null; } }); return map; } /** * Keys of cells with a PENDING update only — drives the amber pending dot. * Once a change is committed the dot clears even though the optimistic value * (from `deriveStagedValues`) stays until the refetch lands. */ export function derivePendingCellKeys( changesQueue: QueueItem[], schema: string, table: string, rows: unknown[][], getLocator: (row: unknown[]) => Record, ): Record { const keys: Record = {}; const updates = changesQueue.filter( (c) => c.type === "update" && c.status === "pending" && c.schema === schema && c.table === table && c.primaryKey && c.newData, ); if (updates.length === 0) return keys; rows.forEach((row, rowIdx) => { const loc = getLocator(row); for (const c of updates) { const pk = c.primaryKey!; const matches = Object.entries(pk).every( ([k, v]) => String(loc[k]) === String(v), ); if (!matches) continue; const colName = Object.keys(c.newData!)[0]; if (!colName) continue; keys[`${rowIdx}:${colName}`] = true; } }); return keys; } /** * Pick a human-friendly display column for FK option labels from the * referenced table's columns: prefer name-like columns, else the first * text-ish column that isn't the ref column, else the ref column itself. */ export function pickDisplayColumn( columns: { name: string; data_type: string }[], refCol: string, preferred?: string, ): string { if (preferred && columns.some((c) => c.name === preferred)) return preferred; const nameLike = [ "name", "title", "label", "username", "email", "full_name", "display_name", "first_name", "last_name", "description", ]; for (const n of nameLike) { if (columns.some((c) => c.name === n)) return n; } const textish = columns.find( (c) => c.name !== refCol && /text|char|name|uuid/i.test(c.data_type), ); return textish ? textish.name : refCol; } export interface DbViewerScreenProps { connectionId: string; onHome: () => void; onSettings: () => void; } export function DbViewerScreen({ connectionId, onHome, onSettings, }: DbViewerScreenProps) { const { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword } = useDbConnection(connectionId); const [dismissedError, setDismissedError] = useState(null); const [currentView, setCurrentView] = useState("db-viewer"); const [tablePanelWidth, setTablePanelWidth] = useState(280); const [queriesPanelWidth, setQueriesPanelWidth] = useState(280); const [searchQuery, setSearchQuery] = useState(""); const [selectedRows, setSelectedRows] = useState>(new Set()); const [rowDetailIdx, setRowDetailIdx] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); const [destructiveQuery, setDestructiveQuery] = useState(null); const connections = useConnectionStore((s) => s.connections); const currentConnection = connections.find((c) => c.id === connectionId) ?? null; const capabilities = getCapabilities(currentConnection?.db_type ?? "postgresql"); const isRedisUnsupported = (currentConnection?.db_type ?? "postgresql") === "redis" && !capabilities.explorer; const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer"; const viewSupported = capabilities[viewCapabilityKey]; const settings = useSettingsStore((s) => s.settings); const notify = useNotificationStore((s) => s.notify); const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize); const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter); const setFilterRules = useDbViewerStore((s) => s.setFilterRules); const setSortRules = useDbViewerStore((s) => s.setSortRules); const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn); const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied); const setObjectSearchOpen = useDbViewerStore((s) => s.setObjectSearchOpen); const requestedView = useDbViewerStore((s) => s.requestedView); // Consume the search palette's navigation request: switch the local // currentView state to the requested view, then clear it so a second // request for the same view still fires. useEffect(() => { if (!requestedView) return; setCurrentView(requestedView); useDbViewerStore.getState().setRequestedView(null); }, [requestedView]); // Sync settings defaults to store useEffect(() => { if (settings?.table_page_size) { setDefaultPageSize(settings.table_page_size); } }, [settings?.table_page_size, setDefaultPageSize]); const panelResizeRef = useRef<{ startX: number; startW: number } | null>( null, ); const queriesPanelResizeRef = useRef<{ startX: number; startW: number; } | null>(null); const activeTab = useDbViewerStore((s) => { if (!s.activeTabId) return null; return s.tabs.find((t) => t.id === s.activeTabId) ?? null; }); // Derive per-tab toolbar state from active tab const filterRules = activeTab?.filterRules ?? []; const sortRules = activeTab?.sortRules ?? []; const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []); const changesQueue = useDbViewerStore((s) => s.changesQueue); const tables = useDbViewerStore((s) => s.tables); const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit); const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading; const isMatview = activeTab && activeTab.tabType === "table" ? tables.some( (t) => t.schema === activeTab.schema && t.name === activeTab.table && t.table_type === "MATERIALIZED VIEW", ) : false; // Regular views (SQLite + PG) are read-only like matviews: they expose no // row locator (no ctid/rowid) and cannot be modified via SQLite, so hide // all data-modifying affordances for them as well. const isView = activeTab && activeTab.tabType === "table" ? tables.some( (t) => t.schema === activeTab.schema && t.name === activeTab.table && t.table_type === "VIEW", ) : false; const readOnlyTable = isMatview || isView; const setTabData = useDbViewerStore((s) => s.setTabData); const setTabError = useDbViewerStore((s) => s.setTabError); const setTabLoading = useDbViewerStore((s) => s.setTabLoading); const databases = useDbViewerStore((s) => s.databases); const currentDatabase = useDbViewerStore((s) => s.currentDatabase); const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase); const schemas = useDbViewerStore((s) => s.schemas); const currentSchema = useDbViewerStore((s) => s.currentSchema); const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema); const fetchingRef = useRef>(new Set()); // CellEditor options for the active table tab: PG enum labels (cached per // connection+schema) + FK reference rows (page 1, 50 per FK column). const [editorOptions, setEditorOptions] = useState<{ enums: Record; fks: Record; fkPlaceholders: Record; } | null>(null); const enumCacheRef = useRef>(new Map()); // Key identifying the (connection, tab, schema) the options were fetched for; // guards against refetching on every render while data updates in place. const editorOptionsKeyRef = useRef(""); const fetchData = useCallback( async (tab: NonNullable) => { if (fetchingRef.current.has(tab.id)) return; fetchingRef.current.add(tab.id); try { const result = await cmd.getTableData( connectionId, tab.schema, tab.table, tab.page, tab.pageSize, tab.filterRules, tab.sortRules, ); setTabData(tab.id, result); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setTabError(tab.id, msg); } finally { fetchingRef.current.delete(tab.id); } }, [connectionId, setTabData, setTabError], ); async function executeQueryForTab(tabId: string, sql: string) { const tab = useDbViewerStore.getState().tabs.find((t) => t.id === tabId); if (!tab) return; setTabLoading(tabId, true); try { const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize); setTabData(tabId, result); useQueryStore.getState().invalidateHistory(connectionId); if (isSchemaModifyingQuery(sql)) { const st = useDbViewerStore.getState(); void st.refreshTree(connectionId, st.currentSchema ?? undefined); } } catch (e) { setTabError(tabId, e instanceof Error ? e.message : String(e)); useQueryStore.getState().invalidateHistory(connectionId); } } const handleStageEdit = useCallback( (payload: { type: "update"; schema: string; table: string; primaryKey: Record; oldData: Record; newData: Record; }) => { if (!activeTab) return; const { type: _, ...rest } = payload; stageCellEdit({ tabId: activeTab.id, ...rest }); }, [activeTab, stageCellEdit], ); const handleOpenRowDetail = useCallback((rowIndex: number) => { setRowDetailIdx(rowIndex); }, []); const handleCommitted = useCallback(() => { if (!activeTab) return; fetchData(activeTab); }, [activeTab, fetchData]); // Read the active tab from the store directly so the Monaco keybinding action // (which keeps the first onRun closure) always sees the latest query text. const handleRunQuery = useCallback(() => { const state = useDbViewerStore.getState(); const tab = state.tabs.find((t) => t.id === state.activeTabId); if (!tab || tab.tabType !== "query") return; const sql = tab.query?.trim() ?? ""; if (!sql) return; if (isDestructiveQuery(sql)) { setDestructiveQuery(sql); } else { executeQueryForTab(tab.id, sql); } }, []); // Auto-format the active query tab's SQL const handleFormatQuery = useCallback(() => { const state = useDbViewerStore.getState(); const tab = state.tabs.find((t) => t.id === state.activeTabId); if (!tab || tab.tabType !== "query") return; const dbType = currentConnection?.db_type ?? "postgresql"; const language = dbType === "mysql" ? "mysql" : dbType === "sqlite" ? "sqlite" : "postgresql"; try { const formatted = formatSql(tab.query ?? "", { language }); useDbViewerStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === tab.id ? { ...t, query: formatted } : t, ), })); } catch { // leave the query untouched if formatting fails } }, [currentConnection?.db_type]); // Restore SQL from history/saved panel: fill active query tab or open a new one const handleRestoreSql = useCallback((sql: string) => { const state = useDbViewerStore.getState(); const tab = state.tabs.find((t) => t.id === state.activeTabId); if (tab && tab.tabType === "query") { useDbViewerStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === tab.id ? { ...t, query: sql } : t, ), })); } else { state.openQueryTab(); requestAnimationFrame(() => { const ns = useDbViewerStore.getState(); const nt = ns.tabs.find((t) => t.id === ns.activeTabId); if (nt) { useDbViewerStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === nt.id ? { ...t, query: sql } : t, ), })); } }); } }, []); // Run SQL from history/saved panel: always open a new tab and execute const handleRunFromHistory = useCallback((sql: string) => { const state = useDbViewerStore.getState(); state.openQueryTab(); requestAnimationFrame(() => { const ns = useDbViewerStore.getState(); const nt = ns.tabs.find((t) => t.id === ns.activeTabId); if (nt) { useDbViewerStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === nt.id ? { ...t, query: sql } : t, ), })); if (isDestructiveQuery(sql)) { setDestructiveQuery(sql); } else { executeQueryForTab(nt.id, sql); } } }); }, [connectionId]); // Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts) useShortcut("close_tab", () => { const state = useDbViewerStore.getState(); if (state.activeTabId) { state.closeTab(state.activeTabId); } else { onHome(); } }); useShortcut("command_palette", () => { if (capabilities.objects) { setObjectSearchOpen(true); } }); useEffect(() => { if (!activeTab) return; if (activeTab.tabType !== "table") return; if (!activeTab.loading) return; if (activeTab.error) return; fetchData(activeTab); }, [activeTab, fetchData]); // Feed the grid's CellEditor with enum labels + FK reference rows for the // active table tab. Fetched once per tab/schema (enums additionally cached // per connection+schema across tabs); a failed fetch for one FK column is // skipped without breaking the tab. Never refetches on in-place data updates. useEffect(() => { const key = activeTab && activeTab.tabType === "table" && activeTab.data ? `${connectionId}:${activeTab.id}:${activeTab.schema}` : ""; if (key === editorOptionsKeyRef.current) return; editorOptionsKeyRef.current = key; if (!key || !activeTab || !activeTab.data) { setEditorOptions(null); return; } const cols = activeTab.data.columns; const tab = activeTab; void (async () => { const enums: Record = {}; const fks: Record = {}; const fkPlaceholders: Record = {}; // PG enums: fetched once per connection+schema, reused across tabs. const cacheKey = `${connectionId}:${tab.schema}`; let enumList = enumCacheRef.current.get(cacheKey); if (!enumList) { try { enumList = await cmd.getEnums(connectionId, tab.schema); enumCacheRef.current.set(cacheKey, enumList); } catch { enumList = []; } } for (const col of cols) { const match = enumList.find((e) => e.name === col.data_type); if (match) enums[col.name] = match.labels; } // FK options: referenced rows (page 1, 50) per FK column. const fkCols = cols.filter((c) => c.is_fk && c.fk_ref); await Promise.all( fkCols.map(async (col) => { const [refTable, refCol] = col.fk_ref!; try { const result = await cmd.getTableData( connectionId, tab.schema, refTable, 1, 50, ); const refIdx = result.columns.findIndex( (c) => c.name === refCol, ); if (refIdx >= 0) { const displayCol = pickDisplayColumn( result.columns, refCol, ); const displayIdx = displayCol === refCol ? refIdx : result.columns.findIndex( (c) => c.name === displayCol, ); fks[col.name] = result.rows.map((row) => { const refValue = String(row[refIdx]); const dispValue = displayIdx >= 0 && displayIdx !== refIdx ? String(row[displayIdx]) : ""; return { value: refValue, label: dispValue && dispValue !== refValue ? `${refValue} — ${dispValue}` : refValue, // Referenced-row cells for the FK-reference-style // one-row dropdown (first 5 columns). cells: result.columns .slice(0, 5) .map((c, i) => ({ name: c.name, value: String(row[i] ?? ""), })), }; }); fkPlaceholders[col.name] = `Search ${refTable}…`; } } catch { // Skip this FK column; the cell keeps the plain editor. } }), ); // Apply only if no newer fetch superseded this one (tab/schema switched). if (editorOptionsKeyRef.current === key) { setEditorOptions({ enums, fks, fkPlaceholders }); } })(); }, [activeTab, connectionId]); // Smart default sort: apply once when data first loads for a tab useEffect(() => { if (!activeTab) return; if (activeTab.tabType !== "table") return; if (activeTab.loading) return; if (!activeTab.data) return; if (activeTab.smartSortApplied) return; const cols = activeTab.data.columns; const getColType = (name: string) => { const col = cols.find( (c) => c.name.toLowerCase() === name.toLowerCase(), ); return col?.data_type.toLowerCase() ?? ""; }; const isNumeric = (name: string) => { const t = getColType(name); return [ "integer", "int", "int2", "int4", "int8", "smallint", "bigint", "serial", "bigserial", "smallserial", "tinyint", "mediumint", "numeric", "decimal", "real", "float", "float4", "float8", "double precision", "double", "number", ].includes(t); }; const isTimestamp = (name: string) => { const t = getColType(name); return [ "timestamp", "timestamptz", "timestamp without time zone", "timestamp with time zone", "date", "datetime", "datetime2", "smalldatetime", ].some((pt) => t.includes(pt)); }; // Find the first column name that exists and passes type checks const findCol = ( candidates: string[], numericOnly = false, ): string | undefined => { for (const cand of candidates) { const match = cols.find( (c) => c.name.toLowerCase() === cand.toLowerCase(), ); if (!match) continue; if (numericOnly && !isNumeric(match.name)) continue; return match.name; } return undefined; }; const findBySuffix = ( suffixes: string[], numericOnly = false, ): string | undefined => { for (const c of cols) { const name = c.name.toLowerCase(); if (suffixes.some((s) => name.endsWith(s))) { if (numericOnly && !isNumeric(c.name)) continue; return c.name; } } return undefined; }; const findByPrefix = ( prefixes: string[], numericOnly = false, ): string | undefined => { for (const c of cols) { const name = c.name.toLowerCase(); if (prefixes.some((p) => name.startsWith(p))) { if (numericOnly && !isNumeric(c.name)) continue; return c.name; } } return undefined; }; // Priority-ordered rules: each returns [columnName | undefined, order] const rules: Array<() => [string | undefined, "asc" | "desc"]> = [ // Tier 1: Explicit recency columns () => [ findCol([ "updated_at", "modified_at", "changed_at", "altered_at", "revised_at", ]), "desc", ], () => [ findCol([ "created_at", "inserted_at", "added_at", "published_at", "posted_at", "registered_at", ]), "desc", ], () => [ findCol([ "deleted_at", "removed_at", "expired_at", "archived_at", ]), "desc", ], // Tier 2: Generic date/timestamp columns (DESC = newest) () => { const col = cols.find((c) => isTimestamp(c.name)); return col ? [col.name, "desc"] : [undefined, "desc"]; }, // Tier 3: Any *_at suffix (covers updated_at, created_at, etc. in any casing) () => [findBySuffix(["_at"]), "desc"], // Tier 4: Any *_on suffix (e.g. action_on, performed_on) () => [findBySuffix(["_on"]), "desc"], // Tier 5: last_* prefix (e.g. last_login, last_seen, last_modified) () => [findByPrefix(["last_"]), "desc"], // Tier 6: Numeric ID (DESC = highest/newest) () => [findCol(["id", "uid", "pk"], true), "desc"], // Tier 7: Any *_id suffix (numeric FKs usually increment) () => [findBySuffix(["_id"], true), "desc"], // Tier 8: Sequence/order columns (ASC = natural order) () => [ findCol( [ "seq", "sequence", "ordinal", "sort", "sort_order", "sortorder", "position", "pos", "display_order", ], true, ), "asc", ], // Tier 9: Rank/priority (ASC if lower = higher priority, DESC if higher = more) () => [ findCol( [ "rank", "ranking", "priority", "weight", "score", "rating", ], true, ), "desc", ], // Tier 10: Version/revision tracking (DESC = latest) () => [ findCol( ["version", "revision", "rev", "build", "release"], true, ), "desc", ], // Tier 11: Count/quantity (DESC = most) () => [ findCol( [ "count", "total", "amount", "quantity", "qty", "num", "number", "no", ], true, ), "desc", ], ]; for (const rule of rules) { const [colName, order] = rule(); if (colName) { setSmartSortApplied(activeTab.id); setSortRules(activeTab.id, [ { id: crypto.randomUUID(), column: colName, order }, ]); return; } } }, [activeTab]); // Sync tab columnFilter (set by FK popover) into the toolbar filterRules useEffect(() => { if (!activeTab?.columnFilter) return; const { column, value } = activeTab.columnFilter; const currentRules = activeTab.filterRules ?? []; const exists = currentRules.some( (r) => r.column === column && r.value === value, ); if (exists) return; setFilterRules(activeTab.id, [ ...currentRules, { id: crypto.randomUUID(), column, operator: "contains" as const, value, }, ]); }, [activeTab?.columnFilter, activeTab?.id, activeTab?.filterRules, setFilterRules]); // When the FK filter rule is removed from the toolbar, clear the tab's columnFilter useEffect(() => { if (!activeTab?.columnFilter) return; const { column, value } = activeTab.columnFilter; const stillExists = filterRules.some( (r) => r.column === column && r.value === value, ); if (!stillExists) { clearColumnFilter(activeTab.id); } }, [filterRules, activeTab, clearColumnFilter]); // Refresh: clear data so auto-fetch effect re-fetches; for query tabs, re-run the stored query const handleRefresh = useCallback(() => { const tabId = useDbViewerStore.getState().activeTabId; if (!tabId) return; const tab = useDbViewerStore.getState().tabs.find((t) => t.id === tabId); if (!tab) return; if (tab.tabType === "query") { const sql = tab.query?.trim() ?? ""; if (sql) executeQueryForTab(tabId, sql); return; } useDbViewerStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === tabId ? { ...t, loading: true, error: null } : t, ), })); }, []); const rawRows = activeTab?.data?.rows ?? []; const columns = activeTab?.data?.columns ?? []; // Data is already filtered and sorted server-side; no client-side transform needed. const processedRows = rawRows; const onPanelResizeStart = useCallback( (e: React.MouseEvent) => { e.preventDefault(); panelResizeRef.current = { startX: e.clientX, startW: tablePanelWidth, }; const onMove = (ev: MouseEvent) => { if (!panelResizeRef.current) return; const w = Math.max( 180, Math.min( 600, panelResizeRef.current.startW + (ev.clientX - panelResizeRef.current.startX), ), ); setTablePanelWidth(w); }; const onUp = () => { panelResizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [tablePanelWidth], ); const onQueriesPanelResizeStart = useCallback( (e: React.MouseEvent) => { e.preventDefault(); queriesPanelResizeRef.current = { startX: e.clientX, startW: queriesPanelWidth, }; const onMove = (ev: MouseEvent) => { if (!queriesPanelResizeRef.current) return; const w = Math.max( 180, Math.min( 600, queriesPanelResizeRef.current.startW + (ev.clientX - queriesPanelResizeRef.current.startX), ), ); setQueriesPanelWidth(w); }; const onUp = () => { queriesPanelResizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [queriesPanelWidth], ); // Query results panel: collapsible + resizable (min 120px, max 80% of column) const queryColumnRef = useRef(null); const resultsResizeRef = useRef<{ startY: number; startH: number } | null>( null, ); const [resultsHeight, setResultsHeight] = useState(() => Math.round( (typeof window !== "undefined" ? window.innerHeight : 800) * 0.4, ), ); const [resultsCollapsed, setResultsCollapsed] = useState(false); const onResultsResizeStart = useCallback( (e: React.MouseEvent) => { e.preventDefault(); resultsResizeRef.current = { startY: e.clientY, startH: resultsHeight, }; const columnH = queryColumnRef.current?.clientHeight || (typeof window !== "undefined" ? window.innerHeight : 800); const maxH = Math.max(120, Math.round(columnH * 0.8)); const onMove = (ev: MouseEvent) => { if (!resultsResizeRef.current) return; const h = resultsResizeRef.current.startH + (resultsResizeRef.current.startY - ev.clientY); setResultsHeight(Math.max(120, Math.min(maxH, h))); }; const onUp = () => { resultsResizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [resultsHeight], ); const handleNavigate = useCallback( (view: string) => { if (view === "home") onHome(); else if (view === "settings") onSettings(); else setCurrentView(view); }, [onHome, onSettings], ); const activeSchema = activeTab?.schema ?? ""; const activeTable = activeTab?.table ?? ""; function renderQueryWorkspace() { const getLocator = (row: unknown[]) => { const pkCol = columns.find((c) => c.is_pk); if (pkCol) { const pkIndex = columns.findIndex( (c) => c.name === pkCol.name, ); return { [pkCol.name]: row[pkIndex] }; } const dbType = currentConnection?.db_type ?? "postgresql"; const locatorIndex = columns.length; if (dbType === "sqlite") { return { rowid: row[locatorIndex] }; } return { ctid: row[locatorIndex] }; }; // Staged cell values derived from the changes queue (single source of // truth): keyed `${rowIndex}:${colName}` → optimistic value. Pending + // committed updates survive until refetch; Clear All empties the queue // so the optimistic display and pending dots vanish immediately. const stagedValues = activeTab?.data ? deriveStagedValues( changesQueue, activeTab.schema, activeTab.table, activeTab.data.rows, getLocator, ) : {}; const pendingKeys = activeTab?.data ? derivePendingCellKeys( changesQueue, activeTab.schema, activeTab.table, activeTab.data.rows, getLocator, ) : {}; return (
{!activeTab ? (
{currentView === "queries" ? ( ) : currentView === "objects" ? ( ) : ( )} {currentView === "queries" ? "Open a new query tab or run a query from the history" : currentView === "objects" ? "Open an object from the list, or open a new query tab" : "Select a table from the tree to browse its data, or open a new query tab"}
) : activeTab?.tabType === "objectForm" ? ( ) : activeTab?.tabType === "object" ? ( ) : activeTab?.tabType === "query" ? ( Loading editor...
} >
{ try { await cancelQuery(connectionId); notify("Query cancelled", "info"); } catch (e) { notify( e instanceof Error ? e.message : String(e), "error", ); } }} />
useDbViewerStore.setState( (s) => ({ tabs: s.tabs.map( (t) => t.id === activeTab.id ? { ...t, query: value, } : t, ), }), ) } onRun={handleRunQuery} />
{!resultsCollapsed ? ( <>
setResultsHeight( Math.round( (typeof window !== "undefined" ? window .innerHeight : 800) * 0.4, ), ) } className="h-1 cursor-row-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50" /> {/* caret pill, centered on the drag strip */}
{activeTab?.data && ( toggleHiddenColumn( activeTab!.id, col, ) } onRefresh={ handleRefresh } filterRules={ filterRules } onFilterChange={( rules, ) => setFilterRules( activeTab!.id, rules, ) } sortRules={ sortRules } onSortChange={( rules, ) => setSortRules( activeTab!.id, rules, ) } defaultRefreshRate={ settings?.table_refresh_rate ?? 0 } selectedCount={ selectedRows.size } selectedRows={processedRows.filter( (_, i) => selectedRows.has( i, ), )} onClearSelection={() => setSelectedRows( new Set(), ) } variant="query" isMatview={readOnlyTable} /> )}
{ setSelectedRows( (prev) => { const next = new Set( prev, ); if ( next.has( rowIndex, ) ) next.delete( rowIndex, ); else next.add( rowIndex, ); return next; }, ); }} onToggleAll={() => { setSelectedRows( (prev) => { if ( prev.size === processedRows.length && processedRows.length > 0 ) { return new Set(); } return new Set( processedRows.map( (_, i) => i, ), ); }, ); }} />
) : ( <> {/* collapsed caret pill, pinned to the bottom of the editor */}
)} { if ( destructiveQuery && activeTab ) { executeQueryForTab( activeTab.id, destructiveQuery, ); } setDestructiveQuery(null); }} onCancel={() => setDestructiveQuery(null) } />
) : ( <> {activeTab?.error && (
{activeTab.error}
)} {activeTab && ( toggleHiddenColumn( activeTab!.id, col, ) } onRefresh={handleRefresh} filterRules={filterRules} onFilterChange={(rules) => setFilterRules( activeTab!.id, rules, ) } sortRules={sortRules} onSortChange={(rules) => setSortRules( activeTab!.id, rules, ) } defaultRefreshRate={ settings?.table_refresh_rate ?? 0 } selectedCount={selectedRows.size} selectedRows={processedRows.filter( (_, i) => selectedRows.has(i), )} onClearSelection={() => setSelectedRows(new Set()) } isMatview={readOnlyTable} /> )}
{ setSelectedRows((prev) => { const next = new Set( prev, ); if (next.has(rowIndex)) next.delete( rowIndex, ); else next.add(rowIndex); return next; }); }} onToggleAll={() => { setSelectedRows((prev) => { if ( prev.size === processedRows.length && processedRows.length > 0 ) { return new Set(); } return new Set( processedRows.map( (_, i) => i, ), ); }); }} />
)} {rowDetailIdx !== null && activeTab?.data && ( setRowDetailIdx(null)} onCopy={(value) => navigator.clipboard.writeText(value).catch(() => {}) } /> )}
); } return (
{connectionError && connectionError !== dismissedError && ( { setDismissedError(null); connect(); }} onDismiss={() => setDismissedError(connectionError)} /> )} {isRedisUnsupported ? (
Redis browsing isn't supported yet — this connection can be tested and used from the Home screen.
) : !viewSupported ? (
{currentView.replace("-", " ")} is unsupported for {currentConnection?.db_type}
) : currentView === "db-viewer" ? (
setEditModalOpen(true)} connectionId={connectionId} dbType={currentConnection?.db_type} searchQuery={searchQuery} onSearchChange={setSearchQuery} />
{/* panel resize handle */}
setTablePanelWidth(280)} /> {renderQueryWorkspace()}
) : currentView === "objects" ? (
setTablePanelWidth(280)} /> {renderQueryWorkspace()}
) : currentView === "tools" ? ( ) : currentView === "queries" ? (
setQueriesPanelWidth(280)} /> {renderQueryWorkspace()}
) : currentView === "schema-visualizer" ? ( { useDbViewerStore.getState().setCurrentSchema(newSchema); }} /> ) : null}
{currentConnection && ( setEditModalOpen(false)} onSaved={() => {}} /> )} {capabilities.objects && ( )}
); }