diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97bfa34c..607b6e0d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -185,6 +185,14 @@ Multi-statement queries execute sequentially via `POST /api/db/multi-query`. - **React hooks** for UI state: tabs, active connection, execution status - **Custom hooks** extracted from Studio.tsx: `useAuth`, `useConnectionManager`, `useTabManager`, `useTransactionControl`, `useQueryExecution`, `useInlineEditing` +Double-clicking an Explorer table uses `useTabManager.handleTableClick`, the same action as +Select Top 50: it opens a new query tab and executes the provider's preview query. A session-only +`QueryTab.previewQuery` records that generated query. BottomPanel's **Refresh rows** reruns it +on the same tab through the standalone or embedded execution hook; it is hidden after query +edits and for stored agent results, and disabled during execution, pagination or pending cell +edits. **Refresh schema** separately invokes the current connection's schema fetch, including +from empty/error states, without executing a query or replacing editor tabs. + ### 4.6. Workspace Abstraction (npm package embedding) Studio ships both as a standalone app and as the `@libredb/studio` npm package consumed by libredb-platform (built with `tsup` via `build:lib`). diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx index 30258b22..b2407f5d 100644 --- a/src/components/Studio.tsx +++ b/src/components/Studio.tsx @@ -397,6 +397,10 @@ export default function Studio() { tabMgr.handleTableClick(tableName, queryExec.executeQuery); }; + const onRefreshSchema = () => { + if (conn.activeConnection) conn.fetchSchema(conn.activeConnection); + }; + const requestDeleteConnection = (id: string) => { setPendingDeleteConnectionId(id); }; @@ -494,6 +498,7 @@ export default function Studio() { }} onAddConnection={() => setIsConnectionModalOpen(true)} onTableClick={onTableClick} + onRefreshSchema={onRefreshSchema} onGenerateSelect={tabMgr.handleGenerateSelect} onCreateTableClick={() => setIsCreateTableModalOpen(true)} onShowDiagram={() => setShowDiagram(true)} @@ -623,6 +628,7 @@ export default function Studio() { onTableClick(tableName); setActiveMobileTab("editor"); }} + onRefreshSchema={onRefreshSchema} onGenerateSelect={(tableName) => { tabMgr.handleGenerateSelect(tableName); setActiveMobileTab("editor"); @@ -687,6 +693,7 @@ export default function Studio() { void; + onRefreshSchema?: () => void; onGenerateSelect?: (tableName: string) => void; onCreateTableClick?: () => void; isAdmin?: boolean; @@ -35,6 +35,7 @@ export function SchemaExplorer({ isLoadingSchema, schemaError = null, onTableClick, + onRefreshSchema, onGenerateSelect, onCreateTableClick, isAdmin = false, @@ -48,6 +49,18 @@ export function SchemaExplorer({ const capabilities = metadata?.capabilities; const [searchQuery, setSearchQuery] = useState(""); const [expandedTables, setExpandedTables] = useState>(new Set()); + const refreshButton = onRefreshSchema && ( + + + + ); const toggleTable = useCallback((tableName: string) => { setExpandedTables((prev) => { @@ -80,6 +93,7 @@ export function SchemaExplorer({ Scanning Schema... + {refreshButton} ); } @@ -99,6 +113,7 @@ export function SchemaExplorer({ Schema could not be read {schemaError} + {refreshButton} ); } @@ -113,6 +128,7 @@ export function SchemaExplorer({ We couldn't find any tables or views in this connection. + {refreshButton} {capabilities?.supportsCreateTable !== false && ( Explorer + {refreshButton} {isAdmin && ( onTableClick?.(table.name)} + title={onTableClick ? "Double-click to preview data" : undefined} > diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 9cb90023..431da622 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -23,6 +23,7 @@ interface SidebarProps { onEditConnection?: (conn: DatabaseConnection) => void; onAddConnection: () => void; onTableClick?: (tableName: string) => void; + onRefreshSchema?: () => void; onGenerateSelect?: (tableName: string) => void; onCreateTableClick?: () => void; onShowDiagram?: () => void; @@ -46,6 +47,7 @@ export function Sidebar({ onEditConnection, onAddConnection, onTableClick, + onRefreshSchema, onGenerateSelect, onCreateTableClick, onShowDiagram, @@ -106,6 +108,7 @@ export function Sidebar({ isLoadingSchema={isLoadingSchema} schemaError={schemaError} onTableClick={onTableClick} + onRefreshSchema={onRefreshSchema} onGenerateSelect={onGenerateSelect} onCreateTableClick={onCreateTableClick} isAdmin={isAdmin} diff --git a/src/components/studio/BottomPanel.tsx b/src/components/studio/BottomPanel.tsx index e3f6410d..78d44409 100644 --- a/src/components/studio/BottomPanel.tsx +++ b/src/components/studio/BottomPanel.tsx @@ -26,6 +26,7 @@ import { GitCompare, LayoutDashboard, LayoutGrid, + RefreshCw, Terminal, X, Zap, @@ -159,6 +160,7 @@ interface BottomPanelProps { // Actions onLoadQuery: (query: string) => void; onLoadMore: (() => void) | undefined; + onRefreshResults?: (query: string, tabId: string) => void; isLoadingMore: boolean | undefined; // The writer's own type, so a format added there cannot silently fail to reach this // menu — the drift between two spellings of one list is what this PR is about. @@ -197,6 +199,7 @@ export function BottomPanel({ onDiscardChanges, onLoadQuery, onLoadMore, + onRefreshResults, isLoadingMore, onExportResults, agentArtifact = null, @@ -349,6 +352,24 @@ export function BottomPanel({ {displayedResult.rowCount} rows • {displayedResult.executionTime}ms + {!hydratedHere && + activeConnection && + onRefreshResults && + currentTab.previewQuery && + currentTab.query === currentTab.previewQuery && ( + 0} + onClick={() => onRefreshResults(currentTab.previewQuery!, currentTab.id)} + > + + Refresh rows + + )} { + if (conn.activeConnection) conn.fetchSchema(conn.activeConnection); + }; + // === No-op callbacks for disabled features === /** What the panel group may hold: below the breakpoint, only the body panel. */ const isMobile = useIsMobile(); @@ -319,6 +323,7 @@ export function StudioWorkspace({ onEditConnection={noop} onAddConnection={noop} onTableClick={onTableClick} + onRefreshSchema={onRefreshSchema} onGenerateSelect={tabMgr.handleGenerateSelect} onCreateTableClick={undefined} onShowDiagram={features.schemaDiagram ? () => setShowDiagram(true) : undefined} @@ -423,6 +428,7 @@ export function StudioWorkspace({ { }); // --- onTableClick --- + test("preview refresh callbacks use the current connection and explicit query", () => { + connMgrOverride = { activeConnection: pgConn }; + render(); + mockFetchSchema.mockClear(); + act(() => (capturedSidebarProps.onRefreshSchema as () => void)()); + expect(mockFetchSchema).toHaveBeenCalledWith(pgConn); + act(() => (capturedMobileNavProps.onTabChange as (tab: string) => void)("schema")); + mockFetchSchema.mockClear(); + act(() => (capturedSchemaExplorerProps.onRefreshSchema as () => void)()); + expect(mockFetchSchema).toHaveBeenCalledTimes(1); + expect(mockFetchSchema).toHaveBeenCalledWith(pgConn); + act(() => (capturedMobileNavProps.onTabChange as (tab: string) => void)("editor")); + act(() => + (capturedBottomPanelProps.onRefreshResults as (query: string, id: string) => void)( + "SELECT * FROM users LIMIT 50;", + "tab-1", + ), + ); + expect(mockExecuteQuery).toHaveBeenCalledWith("SELECT * FROM users LIMIT 50;", "tab-1"); + }); + test("onTableClick delegates to handleTableClick with executeQuery", () => { render(); const fn = capturedSidebarProps.onTableClick as (name: string) => void; diff --git a/tests/components/StudioWorkspace.test.tsx b/tests/components/StudioWorkspace.test.tsx index 187045d6..2733aa2e 100644 --- a/tests/components/StudioWorkspace.test.tsx +++ b/tests/components/StudioWorkspace.test.tsx @@ -690,6 +690,20 @@ describe("StudioWorkspace", () => { expect(mockHandleTableClick).toHaveBeenCalledWith("users", mockExecuteQuery); }); + test("preview refresh callbacks use the embedded host connection and explicit query", () => { + renderWorkspace(); + mockFetchSchema.mockClear(); + act(() => (capturedSidebarProps.onRefreshSchema as () => void)()); + expect(mockFetchSchema).toHaveBeenCalledWith(dbConn); + act(() => + (capturedBottomPanelProps.onRefreshResults as (query: string, id: string) => void)( + "SELECT * FROM users LIMIT 50;", + "tab-1", + ), + ); + expect(mockExecuteQuery).toHaveBeenCalledWith("SELECT * FROM users LIMIT 50;", "tab-1"); + }); + test("sidebar noop callbacks and references are wired", () => { renderWorkspace(); expect(capturedSidebarProps.onSelectConnection).toBe(mockSetActiveConnection); diff --git a/tests/components/schema-explorer/SchemaExplorer.test.tsx b/tests/components/schema-explorer/SchemaExplorer.test.tsx index ad0ef33e..b1640243 100644 --- a/tests/components/schema-explorer/SchemaExplorer.test.tsx +++ b/tests/components/schema-explorer/SchemaExplorer.test.tsx @@ -30,7 +30,7 @@ mock.module("@/components/schema-explorer/TableItem", () => ({ })); import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { render, within, cleanup } from "@testing-library/react"; +import { render, within, cleanup, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; @@ -94,6 +94,26 @@ function createDefaultProps(overrides: Partial } describe("SchemaExplorer", () => { + test("refresh schema is separate and remains available for empty or failed reads", () => { + const onRefreshSchema = mock(() => {}); + const props = createDefaultProps({ onRefreshSchema }); + const { getByRole, queryByRole, rerender } = render(); + fireEvent.click(getByRole("button", { name: "Refresh schema" })); + expect(onRefreshSchema).toHaveBeenCalledTimes(1); + expect(props.onTableClick).not.toHaveBeenCalled(); + for (const schemaError of [null, "Schema read failed"]) { + rerender(); + fireEvent.click(getByRole("button", { name: "Refresh schema" })); + } + expect(onRefreshSchema).toHaveBeenCalledTimes(3); + rerender(); + const busy = getByRole("button", { name: "Refresh schema" }); + expect(busy.hasAttribute("disabled")).toBe(true); + fireEvent.click(busy); + expect(onRefreshSchema).toHaveBeenCalledTimes(3); + rerender(); + expect(queryByRole("button", { name: "Refresh schema" })).toBeNull(); + }); afterEach(() => { cleanup(); }); diff --git a/tests/components/schema-explorer/TableItem.test.tsx b/tests/components/schema-explorer/TableItem.test.tsx index 7b372e4c..ae406fed 100644 --- a/tests/components/schema-explorer/TableItem.test.tsx +++ b/tests/components/schema-explorer/TableItem.test.tsx @@ -253,6 +253,34 @@ describe("TableItem", () => { // ── Dropdown action callbacks ───────────────────────────────────────────── + test("double-click previews a table through the existing select action", () => { + const onTableClick = mock(() => {}); + const onToggle = mock(() => {}); + const { getByText } = render( + , + ); + const name = getByText("users"); + fireEvent.click(name, { detail: 1 }); + fireEvent.click(name, { detail: 2 }); + fireEvent.doubleClick(name); + expect(onToggle).toHaveBeenCalledTimes(2); + expect(onTableClick).toHaveBeenCalledTimes(1); + expect(onTableClick).toHaveBeenCalledWith("users"); + }); + + test("double-click remains safe when no preview callback is supplied", () => { + const { getByText } = render( + {})} isAdmin={false} />, + ); + expect(() => fireEvent.doubleClick(getByText("users"))).not.toThrow(); + }); + test('onTableClick fires with table name on "Select Top 50" click', () => { const onTableClick = mock((name: string) => { void name; diff --git a/tests/components/sidebar/Sidebar.test.tsx b/tests/components/sidebar/Sidebar.test.tsx index 3fea5943..f215b0d2 100644 --- a/tests/components/sidebar/Sidebar.test.tsx +++ b/tests/components/sidebar/Sidebar.test.tsx @@ -23,8 +23,10 @@ mock.module("@/components/sidebar/ConnectionsList", () => ({ }, })); +let capturedSchemaExplorerProps: Record = {}; mock.module("@/components/schema-explorer", () => ({ SchemaExplorer: (props: Record) => { + capturedSchemaExplorerProps = props; // eslint-disable-next-line @typescript-eslint/no-require-imports const React = require("react"); const schema = props.schema as Array | undefined; @@ -115,6 +117,11 @@ function createDefaultProps(overrides: Record = {}) { } describe("Sidebar", () => { + test("passes the schema refresh action to the explorer", () => { + const onRefreshSchema = mock(() => {}); + render(); + expect(capturedSchemaExplorerProps.onRefreshSchema).toBe(onRefreshSchema); + }); // The version tests mutate a process-wide value. The file happens to run alone // in its group today, but that isolation is incidental - restore it explicitly // so a later regrouping cannot turn this into an order-dependent flake. diff --git a/tests/components/studio/BottomPanel.test.tsx b/tests/components/studio/BottomPanel.test.tsx index 76c5aadd..551e3cef 100644 --- a/tests/components/studio/BottomPanel.test.tsx +++ b/tests/components/studio/BottomPanel.test.tsx @@ -216,6 +216,51 @@ function createDefaultProps(overrides: Partial> = {}) { } describe("BottomPanel", () => { + test("refresh rows re-executes only the unchanged table preview and respects pending work", () => { + const onRefreshResults = mock(() => {}); + const previewTab = { + ...createDefaultProps().currentTab, + query: "SELECT * FROM users LIMIT 50;", + previewQuery: "SELECT * FROM users LIMIT 50;", + result: { rows: [{ id: 1 }], fields: ["id"], rowCount: 1, executionTime: 1 }, + }; + const props = createDefaultProps({ currentTab: previewTab, activeConnection: { id: "c1" }, onRefreshResults }); + const { getByRole, queryByRole, rerender } = render( + )} />, + ); + fireEvent.click(getByRole("button", { name: "Refresh rows" })); + expect(onRefreshResults).toHaveBeenCalledWith(previewTab.previewQuery, previewTab.id); + for (const blocked of [ + { currentTab: { ...previewTab, isExecuting: true } }, + { isLoadingMore: true }, + { pendingChanges: [{}] }, + ]) { + rerender()} />); + const refresh = getByRole("button", { name: "Refresh rows" }); + expect(refresh.hasAttribute("disabled")).toBe(true); + fireEvent.click(refresh); + } + expect(onRefreshResults).toHaveBeenCalledTimes(1); + for (const unavailable of [ + { currentTab: { ...previewTab, query: "DELETE FROM users" } }, + { currentTab: { ...previewTab, previewQuery: undefined } }, + { activeConnection: null }, + { onRefreshResults: undefined }, + { mode: "history" }, + { + agentArtifact: { + surface: "results", + result: previewTab.result, + runId: "r1", + operationId: "query", + correlationId: "c1", + }, + }, + ]) { + rerender()} />); + expect(queryByRole("button", { name: "Refresh rows" })).toBeNull(); + } + }); /* The panel's heavy views are code-split (`React.lazy` in BottomPanel.tsx), so the FIRST render of each one suspends while its dynamic import resolves. `React.lazy` diff --git a/tests/hooks/use-tab-manager.test.ts b/tests/hooks/use-tab-manager.test.ts index e88e3162..a1b95131 100644 --- a/tests/hooks/use-tab-manager.test.ts +++ b/tests/hooks/use-tab-manager.test.ts @@ -268,6 +268,8 @@ describe("useTabManager", () => { const newTab = result.current.tabs[1]; expect(newTab.name).toBe("users"); expect(newTab.query).toBe("SELECT * FROM users LIMIT 50;"); + expect(newTab.previewQuery).toBe(newTab.query); + expect(result.current.tabs[0].previewQuery).toBeUndefined(); expect(newTab.type).toBe("sql"); // Active tab should be the new one
{schemaError}
We couldn't find any tables or views in this connection.