Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
7 changes: 7 additions & 0 deletions src/components/Studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down Expand Up @@ -494,6 +498,7 @@ export default function Studio() {
}}
onAddConnection={() => setIsConnectionModalOpen(true)}
onTableClick={onTableClick}
onRefreshSchema={onRefreshSchema}
onGenerateSelect={tabMgr.handleGenerateSelect}
onCreateTableClick={() => setIsCreateTableModalOpen(true)}
onShowDiagram={() => setShowDiagram(true)}
Expand Down Expand Up @@ -623,6 +628,7 @@ export default function Studio() {
onTableClick(tableName);
setActiveMobileTab("editor");
}}
onRefreshSchema={onRefreshSchema}
onGenerateSelect={(tableName) => {
tabMgr.handleGenerateSelect(tableName);
setActiveMobileTab("editor");
Expand Down Expand Up @@ -687,6 +693,7 @@ export default function Studio() {
<ResizableHandle className="h-1 bg-fill hover:bg-brand-tint/20" />
<ResizablePanel id="studio-editor-bottom" defaultSize="60" minSize="20">
<BottomPanel
onRefreshResults={queryExec.executeQuery}
mode={queryExec.bottomPanelMode}
onSetMode={queryExec.setBottomPanelMode}
currentTab={tabMgr.currentTab}
Expand Down
21 changes: 19 additions & 2 deletions src/components/schema-explorer/SchemaExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
import React, { useState, useMemo, useCallback } from "react";
import { TableSchema } from "@/lib/types";
import type { ProviderMetadata } from "@/hooks/use-provider-metadata";
import { Search, Hash, LoaderCircle, CircleAlert, Database, Plus, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Search, Hash, LoaderCircle, CircleAlert, Database, Plus, Settings, RefreshCw } from "lucide-react";
import { Input } from "@/components/ui/input";
import { AnimatePresence } from "framer-motion";
import { TableItem } from "./TableItem";
Expand All @@ -19,6 +18,7 @@ interface SchemaExplorerProps {
*/
schemaError?: string | null;
onTableClick?: (tableName: string) => void;
onRefreshSchema?: () => void;
onGenerateSelect?: (tableName: string) => void;
onCreateTableClick?: () => void;
isAdmin?: boolean;
Expand All @@ -35,6 +35,7 @@ export function SchemaExplorer({
isLoadingSchema,
schemaError = null,
onTableClick,
onRefreshSchema,
onGenerateSelect,
onCreateTableClick,
isAdmin = false,
Expand All @@ -48,6 +49,18 @@ export function SchemaExplorer({
const capabilities = metadata?.capabilities;
const [searchQuery, setSearchQuery] = useState("");
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
const refreshButton = onRefreshSchema && (
<button
type="button"
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-brand transition-colors disabled:pointer-events-none disabled:opacity-50"
onClick={onRefreshSchema}
disabled={isLoadingSchema}
title="Refresh schema"
aria-label="Refresh schema"
>
<RefreshCw strokeWidth={1.5} className="w-3.5 h-3.5" />
</button>
);

const toggleTable = useCallback((tableName: string) => {
setExpandedTables((prev) => {
Expand Down Expand Up @@ -80,6 +93,7 @@ export function SchemaExplorer({
<Database strokeWidth={1.5} className="w-3.5 h-3.5 absolute inset-0 m-auto text-brand animate-pulse" />
</div>
<span className="text-xs font-medium animate-pulse">Scanning Schema...</span>
{refreshButton}
</div>
);
}
Expand All @@ -99,6 +113,7 @@ export function SchemaExplorer({
</div>
<h3 className="text-foreground text-xs font-medium mb-1">Schema could not be read</h3>
<p className="text-xs text-muted-foreground leading-relaxed break-words">{schemaError}</p>
{refreshButton}
</div>
);
}
Expand All @@ -113,6 +128,7 @@ export function SchemaExplorer({
<p className="text-xs text-muted-foreground leading-relaxed">
We couldn&apos;t find any tables or views in this connection.
</p>
{refreshButton}
{capabilities?.supportsCreateTable !== false && (
<button
className="mt-3 flex items-center gap-1.5 rounded-md bg-brand-solid hover:bg-brand-solid-hover text-white px-3 py-1.5 text-xs font-medium transition-colors"
Expand All @@ -136,6 +152,7 @@ export function SchemaExplorer({
<span className="text-xs font-medium text-muted-foreground">Explorer</span>
</div>
<div className="flex items-center gap-1.5">
{refreshButton}
{isAdmin && (
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-warning transition-colors"
Expand Down
2 changes: 2 additions & 0 deletions src/components/schema-explorer/TableItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ export const TableItem = React.memo(function TableItem({
aria-expanded={isExpanded}
className="flex items-center gap-1.5 flex-1 min-w-0 py-1.5 cursor-pointer text-left"
onClick={onToggle}
onDoubleClick={() => onTableClick?.(table.name)}
title={onTableClick ? "Double-click to preview data" : undefined}
>
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }} className="shrink-0">
<ChevronRight strokeWidth={1.5} className="w-3.5 h-3.5 text-muted-foreground" />
Expand Down
3 changes: 3 additions & 0 deletions src/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -46,6 +47,7 @@ export function Sidebar({
onEditConnection,
onAddConnection,
onTableClick,
onRefreshSchema,
onGenerateSelect,
onCreateTableClick,
onShowDiagram,
Expand Down Expand Up @@ -106,6 +108,7 @@ export function Sidebar({
isLoadingSchema={isLoadingSchema}
schemaError={schemaError}
onTableClick={onTableClick}
onRefreshSchema={onRefreshSchema}
onGenerateSelect={onGenerateSelect}
onCreateTableClick={onCreateTableClick}
isAdmin={isAdmin}
Expand Down
21 changes: 21 additions & 0 deletions src/components/studio/BottomPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
GitCompare,
LayoutDashboard,
LayoutGrid,
RefreshCw,
Terminal,
X,
Zap,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -197,6 +199,7 @@ export function BottomPanel({
onDiscardChanges,
onLoadQuery,
onLoadMore,
onRefreshResults,
isLoadingMore,
onExportResults,
agentArtifact = null,
Expand Down Expand Up @@ -349,6 +352,24 @@ export function BottomPanel({
<span className="hidden @4xl/panel:inline text-xs font-mono text-fg-muted mr-2">
{displayedResult.rowCount} rows • {displayedResult.executionTime}ms
</span>
{!hydratedHere &&
activeConnection &&
onRefreshResults &&
currentTab.previewQuery &&
currentTab.query === currentTab.previewQuery && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-fg-muted gap-1.5"
title="Refresh rows"
aria-label="Refresh rows"
disabled={currentTab.isExecuting || isLoadingMore || pendingChanges.length > 0}
onClick={() => onRefreshResults(currentTab.previewQuery!, currentTab.id)}
>
<RefreshCw strokeWidth={1.5} className="w-3 h-3" />
<span className="hidden @2xl/panel:inline">Refresh rows</span>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
Expand Down
1 change: 1 addition & 0 deletions src/hooks/use-tab-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks
id: newId,
name: tableName,
query: newQuery,
previewQuery: newQuery,
result: null,
isExecuting: false,
type: resolveTabType(capabilities),
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ export interface QueryTab {
id: string;
name: string;
query: string;
/** Original generated preview query; refreshing rows must not execute edited SQL. */
previewQuery?: string;
result: QueryResult | null;
isExecuting: boolean;
type: "sql" | "mongodb" | "redis" | "libredb";
Expand Down
6 changes: 6 additions & 0 deletions src/workspace/StudioWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,10 @@ export function StudioWorkspace({
[tabMgr, queryExec.executeQuery],
);

const onRefreshSchema = () => {
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();
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -423,6 +428,7 @@ export function StudioWorkspace({
<ResizableHandle className="h-1 bg-fill hover:bg-brand-tint/20" />
<ResizablePanel id="workspace-editor-bottom" defaultSize="60" minSize="20">
<BottomPanel
onRefreshResults={queryExec.executeQuery}
mode={queryExec.bottomPanelMode}
onSetMode={queryExec.setBottomPanelMode}
currentTab={tabMgr.currentTab}
Expand Down
21 changes: 21 additions & 0 deletions tests/components/Studio.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,27 @@ describe("Studio", () => {
});

// --- onTableClick ---
test("preview refresh callbacks use the current connection and explicit query", () => {
connMgrOverride = { activeConnection: pgConn };
render(<Studio />);
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(<Studio />);
const fn = capturedSidebarProps.onTableClick as (name: string) => void;
Expand Down
14 changes: 14 additions & 0 deletions tests/components/StudioWorkspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 21 additions & 1 deletion tests/components/schema-explorer/SchemaExplorer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -94,6 +94,26 @@ function createDefaultProps(overrides: Partial<Parameters<typeof SchemaExplorer>
}

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(<SchemaExplorer {...props} />);
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(<SchemaExplorer {...props} schema={[]} schemaError={schemaError} />);
fireEvent.click(getByRole("button", { name: "Refresh schema" }));
}
expect(onRefreshSchema).toHaveBeenCalledTimes(3);
rerender(<SchemaExplorer {...props} isLoadingSchema />);
const busy = getByRole("button", { name: "Refresh schema" });
expect(busy.hasAttribute("disabled")).toBe(true);
fireEvent.click(busy);
expect(onRefreshSchema).toHaveBeenCalledTimes(3);
rerender(<SchemaExplorer {...props} onRefreshSchema={undefined} />);
expect(queryByRole("button", { name: "Refresh schema" })).toBeNull();
});
afterEach(() => {
cleanup();
});
Expand Down
28 changes: 28 additions & 0 deletions tests/components/schema-explorer/TableItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TableItem
table={largeTable}
isExpanded={false}
onToggle={onToggle}
isAdmin={false}
onTableClick={onTableClick}
/>,
);
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(
<TableItem table={largeTable} isExpanded={false} onToggle={mock(() => {})} 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;
Expand Down
7 changes: 7 additions & 0 deletions tests/components/sidebar/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ mock.module("@/components/sidebar/ConnectionsList", () => ({
},
}));

let capturedSchemaExplorerProps: Record<string, unknown> = {};
mock.module("@/components/schema-explorer", () => ({
SchemaExplorer: (props: Record<string, unknown>) => {
capturedSchemaExplorerProps = props;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const React = require("react");
const schema = props.schema as Array<unknown> | undefined;
Expand Down Expand Up @@ -115,6 +117,11 @@ function createDefaultProps(overrides: Record<string, unknown> = {}) {
}

describe("Sidebar", () => {
test("passes the schema refresh action to the explorer", () => {
const onRefreshSchema = mock(() => {});
render(<Sidebar {...createDefaultProps({ onRefreshSchema })} />);
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.
Expand Down
Loading
Loading