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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ And nothing is held back. Single sign-on, ER diagrams, the AI features and the N
- **Smart Autocomplete**: Schema-aware suggestions for tables, columns, and SQL keywords.
- **Command Palette**: Quick access to tables, connections, saved queries, and actions with `Cmd/Ctrl+K`.
- **Multi-Tab Workspace**: Handle parallel tasks with independent execution states.
- **Table Counts**: Explorer badges use compact K/M/B/T notation. For SQL tables, Select Table Count opens an editable `SELECT COUNT(*)` in a new tab; add filters and run it when ready. The existing badge remains the provider's reported count, which may be an estimate.
- **Saved Query Backups**: Export the complete saved-query library as JSON. Import validates the file, preserves query metadata and merges new entries, reporting duplicate IDs while keeping existing queries intact.
- **Duplicate Connections**: Open an independent `(copy)` of an editable saved connection in the connection editor, adjust its settings and save. Cancelling leaves the saved connections unchanged; administrator-managed connections cannot be duplicated.
- **Visual EXPLAIN**: Graphical execution plans to identify performance bottlenecks.
Expand Down
5 changes: 5 additions & 0 deletions src/components/Studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ export default function Studio() {
onAddConnection={() => setIsConnectionModalOpen(true)}
onTableClick={onTableClick}
onGenerateSelect={tabMgr.handleGenerateSelect}
onGenerateCount={tabMgr.handleGenerateCount}
onCreateTableClick={() => setIsCreateTableModalOpen(true)}
onShowDiagram={() => setShowDiagram(true)}
isAdmin={isAdmin}
Expand Down Expand Up @@ -648,6 +649,10 @@ export default function Studio() {
tabMgr.handleGenerateSelect(tableName);
setActiveMobileTab("editor");
}}
onGenerateCount={(tableName) => {
tabMgr.handleGenerateCount(tableName);
setActiveMobileTab("editor");
}}
onCreateTableClick={() => setIsCreateTableModalOpen(true)}
isAdmin={isAdmin}
onOpenMaintenance={openMaintenance}
Expand Down
3 changes: 3 additions & 0 deletions src/components/schema-explorer/SchemaExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface SchemaExplorerProps {
schemaError?: string | null;
onTableClick?: (tableName: string) => void;
onGenerateSelect?: (tableName: string) => void;
onGenerateCount?: (tableName: string) => void;
onCreateTableClick?: () => void;
isAdmin?: boolean;
onOpenMaintenance?: (tab?: "global" | "tables" | "sessions", table?: string) => void;
Expand All @@ -36,6 +37,7 @@ export function SchemaExplorer({
schemaError = null,
onTableClick,
onGenerateSelect,
onGenerateCount,
onCreateTableClick,
isAdmin = false,
onOpenMaintenance,
Expand Down Expand Up @@ -195,6 +197,7 @@ export function SchemaExplorer({
isAdmin={isAdmin}
onTableClick={onTableClick}
onGenerateSelect={onGenerateSelect}
onGenerateCount={onGenerateCount}
onProfileTable={onProfileTable}
onGenerateCode={onGenerateCode}
onGenerateTestData={onGenerateTestData}
Expand Down
24 changes: 22 additions & 2 deletions src/components/schema-explorer/TableItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ import { toast } from "sonner";
import { writeToClipboard } from "@/components/copy-button";
import { ColumnList } from "./ColumnList";

const rowCountFormat = new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
});

interface TableItemProps {
table: TableSchema;
isExpanded: boolean;
Expand All @@ -47,6 +52,7 @@ interface TableItemProps {
isAdmin: boolean;
onTableClick?: (tableName: string) => void;
onGenerateSelect?: (tableName: string) => void;
onGenerateCount?: (tableName: string) => void;
onProfileTable?: (tableName: string) => void;
onGenerateCode?: (tableName: string) => void;
onGenerateTestData?: (tableName: string) => void;
Expand All @@ -55,7 +61,13 @@ interface TableItemProps {

type TableItemCallbacks = Pick<
TableItemProps,
"onTableClick" | "onGenerateSelect" | "onProfileTable" | "onGenerateCode" | "onGenerateTestData" | "onOpenMaintenance"
| "onTableClick"
| "onGenerateSelect"
| "onGenerateCount"
| "onProfileTable"
| "onGenerateCode"
| "onGenerateTestData"
| "onOpenMaintenance"
>;

/**
Expand Down Expand Up @@ -115,6 +127,12 @@ function renderMenuItems({
<Funnel strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-blue" />
{labels?.generateAction || "Generate Query"}
</Item>
{rowsAreAddressable && capabilities?.queryLanguage === "sql" && callbacks.onGenerateCount && (
<Item onClick={() => callbacks.onGenerateCount?.(table.name)}>
<ChartColumn strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-hue-blue" />
Select Table Count
</Item>
)}
<Item onClick={() => copyToClipboard(table.name, `${labels?.entityName || "Table"} name`)}>
<Copy strokeWidth={1.5} className="w-3.5 h-3.5 mr-2 text-muted-foreground" />
{"Copy Name"}
Expand Down Expand Up @@ -198,6 +216,7 @@ export const TableItem = React.memo(function TableItem({
isAdmin,
onTableClick,
onGenerateSelect,
onGenerateCount,
onProfileTable,
onGenerateCode,
onGenerateTestData,
Expand All @@ -217,6 +236,7 @@ export const TableItem = React.memo(function TableItem({
const callbacks = {
onTableClick,
onGenerateSelect,
onGenerateCount,
onProfileTable,
onGenerateCode,
onGenerateTestData,
Expand Down Expand Up @@ -263,7 +283,7 @@ export const TableItem = React.memo(function TableItem({
<div className="shrink-0 relative w-8 h-6 flex items-center justify-center">
{table.rowCount !== undefined && (
<span className="absolute inset-0 flex items-center justify-center text-[0.625rem] font-mono text-muted-foreground/70 whitespace-nowrap opacity-100 group-hover:opacity-0 transition-opacity pointer-events-none">
{table.rowCount >= 1000 ? `${(table.rowCount / 1000).toFixed(1)}k` : table.rowCount}
{rowCountFormat.format(table.rowCount)}
</span>
)}
<DropdownMenu>
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 @@ -25,6 +25,7 @@ interface SidebarProps {
onAddConnection: () => void;
onTableClick?: (tableName: string) => void;
onGenerateSelect?: (tableName: string) => void;
onGenerateCount?: (tableName: string) => void;
onCreateTableClick?: () => void;
onShowDiagram?: () => void;
isAdmin?: boolean;
Expand All @@ -49,6 +50,7 @@ export function Sidebar({
onAddConnection,
onTableClick,
onGenerateSelect,
onGenerateCount,
onCreateTableClick,
onShowDiagram,
isAdmin = false,
Expand Down Expand Up @@ -110,6 +112,7 @@ export function Sidebar({
schemaError={schemaError}
onTableClick={onTableClick}
onGenerateSelect={onGenerateSelect}
onGenerateCount={onGenerateCount}
onCreateTableClick={onCreateTableClick}
isAdmin={isAdmin}
onOpenMaintenance={onOpenMaintenance}
Expand Down
24 changes: 23 additions & 1 deletion src/hooks/use-tab-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState, useCallback, useEffect, useMemo } from "react";
import type { DatabaseConnection, TableSchema, QueryTab } from "@/lib/types";
import type { ProviderMetadata } from "@/hooks/use-provider-metadata";
import { generateTableQuery, generateSelectQuery } from "@/lib/query-generators";
import { generateTableQuery, generateSelectQuery, generateCountQuery } from "@/lib/query-generators";
import { resolveTabType } from "@/lib/editor/tab-language";
import { logger } from "@/lib/logger";
import { newLocalId } from "@/lib/ids";
Expand Down Expand Up @@ -239,6 +239,27 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks
[metadata, schema],
);

const handleGenerateCount = useCallback(
(tableName: string) => {
const query = metadata ? generateCountQuery(tableName, metadata.capabilities) : null;
if (!query) return;
const id = newLocalId();
setTabs((prev) => [
...prev,
{
id,
name: `Count: ${tableName}`,
query,
result: null,
isExecuting: false,
type: "sql",
},
]);
setActiveTabId(id);
},
[metadata],
);

return {
tabs,
setTabs,
Expand All @@ -255,5 +276,6 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks
updateTabById,
handleTableClick,
handleGenerateSelect,
handleGenerateCount,
};
}
9 changes: 7 additions & 2 deletions src/lib/query-generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,8 @@ function redisScan(base: string): string {
* The terminator a generated statement ends with: `;` everywhere, and nothing on a
* product whose grammar has none.
*
* Only the two shapes a user reaches by CLICKING are bounded here - the schema tree's
* "Select Top N" and "Generate Query" - because those are the statements this file
* The shapes a user reaches by CLICKING are bounded here - the schema tree's
* "Select Top N", "Generate Query" and "Select Table Count" - because those are the statements this file
* writes on the user's behalf. The dialect-specific returns above keep their own
* literal `;`: each of those engines accepts one, and this is the fallthrough every
* other SQL engine shares, which is where the two search products land. See
Expand All @@ -330,6 +330,11 @@ function terminator(capabilities: ProviderCapabilities): string {
return capabilities.statementTerminator === "none" ? "" : ";";
}

export function generateCountQuery(tableName: string, capabilities: ProviderCapabilities): string | null {
if (capabilities.queryLanguage !== "sql" || capabilities.tablesAreDerivedGroupings) return null;
return `SELECT COUNT(*) FROM ${quoteQualifiedName(tableName, capabilities)}${terminator(capabilities)}`;
}

/**
* The one refusal both LibreDB generators give for a name they cannot address:
* a `#` note and no command line. A schema-tree node name is a real key name,
Expand Down
1 change: 1 addition & 0 deletions src/workspace/StudioWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export function StudioWorkspace({
onAddConnection={noop}
onTableClick={onTableClick}
onGenerateSelect={tabMgr.handleGenerateSelect}
onGenerateCount={tabMgr.handleGenerateCount}
onCreateTableClick={undefined}
onShowDiagram={features.schemaDiagram ? () => setShowDiagram(true) : undefined}
isAdmin={false}
Expand Down
14 changes: 14 additions & 0 deletions tests/components/Studio.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const mockUpdateCurrentTab = mock(() => {});
const mockUpdateTabById = mock(() => {});
const mockHandleTableClick = mock(() => {});
const mockHandleGenerateSelect = mock(() => {});
const mockHandleGenerateCount = mock(() => {});
// Transaction Control
const mockResetTransactionState = mock(() => {});
const mockSetPlaygroundMode = mock(() => {});
Expand Down Expand Up @@ -154,6 +155,7 @@ mock.module("@/hooks/use-tab-manager", () => ({
updateTabById: mockUpdateTabById,
handleTableClick: mockHandleTableClick,
handleGenerateSelect: mockHandleGenerateSelect,
handleGenerateCount: mockHandleGenerateCount,
...tabMgrOverride,
})),
}));
Expand Down Expand Up @@ -534,6 +536,7 @@ describe("Studio", () => {
mockUpdateTabById.mockClear();
mockHandleTableClick.mockClear();
mockHandleGenerateSelect.mockClear();
mockHandleGenerateCount.mockClear();
mockResetTransactionState.mockClear();
mockHandleTransaction.mockClear();
mockSetPlaygroundMode.mockClear();
Expand Down Expand Up @@ -1663,6 +1666,17 @@ describe("Studio", () => {
expect(queryByTestId("schema-explorer")).toBeNull();
});

test("desktop and mobile schema surfaces generate a count without executing", () => {
connMgrOverride = { activeConnection: pgConn };
const { queryByTestId } = render(<Studio />);
expect(capturedSidebarProps.onGenerateCount).toBe(mockHandleGenerateCount);
act(() => (capturedMobileNavProps.onTabChange as (tab: string) => void)("schema"));
act(() => (capturedSchemaExplorerProps.onGenerateCount as (name: string) => void)("users"));
expect(mockHandleGenerateCount).toHaveBeenCalledWith("users");
expect(mockExecuteQuery).not.toHaveBeenCalled();
expect(queryByTestId("schema-explorer") === null).toBe(true);
});

test("mobile schema tab table tool callbacks open modals and maintenance", () => {
connMgrOverride = { activeConnection: pgConn };
const { queryByTestId } = render(<Studio />);
Expand Down
4 changes: 4 additions & 0 deletions tests/components/StudioWorkspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const mockUpdateCurrentTab = mock(() => {});
const mockUpdateTabById = mock(() => {});
const mockHandleTableClick = mock(() => {});
const mockHandleGenerateSelect = mock(() => {});
const mockHandleGenerateCount = mock(() => {});
// Query adapter
const mockExecuteQuery = mock(() => {});
const mockForceExecuteQuery = mock(() => {});
Expand Down Expand Up @@ -137,6 +138,7 @@ mock.module("@/hooks/use-tab-manager", () => ({
updateTabById: mockUpdateTabById,
handleTableClick: mockHandleTableClick,
handleGenerateSelect: mockHandleGenerateSelect,
handleGenerateCount: mockHandleGenerateCount,
...tabMgrOverride,
};
}),
Expand Down Expand Up @@ -372,6 +374,7 @@ describe("StudioWorkspace", () => {
mockUpdateTabById.mockClear();
mockHandleTableClick.mockClear();
mockHandleGenerateSelect.mockClear();
mockHandleGenerateCount.mockClear();
mockExecuteQuery.mockClear();
mockForceExecuteQuery.mockClear();
mockCancelQuery.mockClear();
Expand Down Expand Up @@ -712,6 +715,7 @@ describe("StudioWorkspace", () => {
renderWorkspace();
expect(capturedSidebarProps.onSelectConnection).toBe(mockSetActiveConnection);
expect(capturedSidebarProps.onGenerateSelect).toBe(mockHandleGenerateSelect);
expect(capturedSidebarProps.onGenerateCount).toBe(mockHandleGenerateCount);
expect(capturedSidebarProps.isAdmin).toBe(false);
// noop callbacks do not throw
act(() => (capturedSidebarProps.onDeleteConnection as () => void)());
Expand Down
9 changes: 9 additions & 0 deletions tests/components/schema-explorer/SchemaExplorer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@ import { setupFramerMotionMock } from "../../helpers/mock-monaco";
setupFramerMotionMock();

// Mock the child TableItem component to simplify testing
let capturedGenerateCount: unknown;
mock.module("@/components/schema-explorer/TableItem", () => ({
TableItem: ({
table,
isExpanded,
onToggle,
onGenerateCount,
}: {
table: { name: string };
isExpanded: boolean;
onToggle: () => void;
onGenerateCount?: (name: string) => void;
}) => {
capturedGenerateCount = onGenerateCount;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const React = require("react");
return React.createElement(
Expand Down Expand Up @@ -94,6 +98,11 @@ function createDefaultProps(overrides: Partial<Parameters<typeof SchemaExplorer>
}

describe("SchemaExplorer", () => {
test("forwards the count action to table rows", () => {
const onGenerateCount = mock(() => {});
render(<SchemaExplorer {...createDefaultProps()} onGenerateCount={onGenerateCount} />);
expect(capturedGenerateCount).toBe(onGenerateCount);
});
afterEach(() => {
cleanup();
});
Expand Down
Loading
Loading