diff --git a/.changeset/sql-result-selection-export.md b/.changeset/sql-result-selection-export.md new file mode 100644 index 00000000..c17e064a --- /dev/null +++ b/.changeset/sql-result-selection-export.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": minor +--- + +Offer the `copy as` selection export in SQL view. Selected result rows and cell ranges copy or save as CSV or Markdown from the SQL toolbar, using the same menu, column order and header option as the table toolbar, so query output no longer has to leave Studio one cell at a time. diff --git a/Architecture/sql-view.md b/Architecture/sql-view.md index 34b19510..22b8621f 100644 --- a/Architecture/sql-view.md +++ b/Architecture/sql-view.md @@ -64,6 +64,7 @@ SQL result visualization is governed by: include client-side render/layout time. - The idle AI-visualization affordance MUST render on the same summary row as the `"row(s) returned in Xms"` text, right-aligned from the row-count copy. - SQL result rows MUST be adapted with a stable synthetic `__ps_rowid` for shared grid row identity. +- SQL result selections MUST expose the shared selection-export menu in the view toolbar, and its selection subscription MUST live in an isolated component so selecting rows or cells never rerenders the SQL editor. - Result columns are dynamic and derived from query output keys. - SQL headers/cells MUST reuse table-view header/cell components (`DataGridHeader`, `getCell`) with synthetic column metadata. - Any mounted AI visualization chart for SQL results MUST live inside the shared scrollable grid header region, so it scrolls with the same container as the result rows. diff --git a/FEATURES.md b/FEATURES.md index a6a8c714..ac2e77c9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -293,7 +293,8 @@ Paste operations map matrix values into selected writable cells, enabling spread ## Selection Export Formats -When rows or cell ranges are selected, the table toolbar adds a compact `copy as` menu for exporting the current selection as Markdown or CSV. +When rows or cell ranges are selected, a compact `copy as` menu appears in the view toolbar for exporting the current selection as Markdown or CSV. +The same menu serves the table toolbar and the SQL toolbar next to the run control, so a query result leaves Studio exactly like table data instead of one cell at a time. Exports can copy directly to the clipboard or save to disk, include column headers by default, and reuse the current grid column order and pinned-column layout so the exported shape matches what users are working with. ## Typed Cell Editing diff --git a/ui/studio/grid/SelectionExportMenu.test.tsx b/ui/studio/grid/SelectionExportMenu.test.tsx new file mode 100644 index 00000000..2d719bfe --- /dev/null +++ b/ui/studio/grid/SelectionExportMenu.test.tsx @@ -0,0 +1,191 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SelectionExportMenu } from "./SelectionExportMenu"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const ROWS = [ + { + __ps_rowid: "row-1", + email: "alice@example.com", + id: "user_1", + }, + { + __ps_rowid: "row-2", + email: "bob@example.com", + id: "user_2", + }, +]; + +afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +async function flush() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function dispatchPointerClick(element: Element | null | undefined) { + if (!element) { + return; + } + + const PointerEventConstructor = window.PointerEvent ?? MouseEvent; + + act(() => { + element.dispatchEvent( + new PointerEventConstructor("pointerdown", { + bubbles: true, + button: 0, + cancelable: true, + }), + ); + element.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + button: 0, + cancelable: true, + }), + ); + }); +} + +function findButtonByText(text: string) { + return Array.from( + document.querySelectorAll("button"), + ).find((button) => button.textContent?.trim() === text); +} + +function findMenuItemByText(text: string) { + return Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.trim() === text); +} + +function renderMenu( + props: Partial[0]> = {}, +) { + const container = document.createElement("div"); + + document.body.appendChild(container); + + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + return { + cleanup() { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("SelectionExportMenu", () => { + it("stays hidden while nothing is selected", () => { + const view = renderMenu(); + + expect(findButtonByText("copy as")).toBeUndefined(); + + view.cleanup(); + }); + + it("copies the selected rows as csv with column headers by default", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + + vi.stubGlobal("navigator", { + ...navigator, + clipboard: { writeText }, + }); + + const view = renderMenu({ + rowSelectionState: { "row-1": true, "row-2": true }, + }); + + dispatchPointerClick(findButtonByText("copy as")); + await flush(); + + dispatchPointerClick(findMenuItemByText("copy csv")); + + expect(writeText).toHaveBeenCalledWith( + "id,email\nuser_1,alice@example.com\nuser_2,bob@example.com", + ); + + view.cleanup(); + }); + + it("saves a cell range using the filename base of the host view", async () => { + const createObjectURL = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:selection-export"); + const revokeObjectURL = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + const anchors: HTMLAnchorElement[] = []; + const createElement = document.createElement.bind(document); + + vi.spyOn(document, "createElement").mockImplementation( + (tagName, options) => { + const element = createElement(tagName, options); + + if (element instanceof HTMLAnchorElement) { + anchors.push(element); + } + + return element; + }, + ); + + const view = renderMenu({ + cellSelectionRange: { + columnEnd: 0, + columnStart: 0, + rowEnd: 1, + rowStart: 0, + }, + }); + + dispatchPointerClick(findButtonByText("copy as")); + await flush(); + + dispatchPointerClick(findMenuItemByText("save csv")); + + expect(click).toHaveBeenCalledTimes(1); + expect(anchors.at(-1)?.download).toBe("sql-result-selection.csv"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:selection-export"); + + const blobArg = createObjectURL.mock.calls[0]?.[0]; + + if (!(blobArg instanceof Blob)) { + throw new Error("Expected selection export download to use a Blob"); + } + + expect(await blobArg.text()).toBe("id\nuser_1\nuser_2"); + + view.cleanup(); + }); +}); diff --git a/ui/studio/grid/SelectionExportMenu.tsx b/ui/studio/grid/SelectionExportMenu.tsx new file mode 100644 index 00000000..f23e3446 --- /dev/null +++ b/ui/studio/grid/SelectionExportMenu.tsx @@ -0,0 +1,199 @@ +import type { RowSelectionState } from "@tanstack/react-table"; +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "../../components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../../components/ui/dropdown-menu"; +import { cn } from "../../lib/utils"; +import type { GridSelectionRange } from "./cell-selection"; +import { + buildCellSelectionExportTable, + buildRowSelectionExportTable, + buildSelectionExportFilename, + downloadSelectionExport, + type SelectionExportFormat, + type SelectionExportTable, + serializeSelectionExport, +} from "./selection-export"; + +interface SelectionExportMenuProps { + cellSelectionRange: GridSelectionRange | null; + columnIds: string[]; + filenameBase: string; + rows: Record[]; + rowSelectionState: RowSelectionState; +} + +/** + * Renders the `copy as` menu for the current grid selection. Any view that + * renders the shared `DataGrid` can mount it, so cell ranges and row + * selections export the same way everywhere. + */ +export function SelectionExportMenu(props: SelectionExportMenuProps) { + const { + cellSelectionRange, + columnIds, + filenameBase, + rowSelectionState, + rows, + } = props; + const [isOpen, setOpen] = useState(false); + const [includeColumnHeader, setIncludeColumnHeader] = useState(true); + const selectedRowCount = + Object.values(rowSelectionState).filter(Boolean).length; + + if (cellSelectionRange == null && selectedRowCount === 0) { + return null; + } + + function buildSelectionExportTable(): SelectionExportTable | null { + if (cellSelectionRange) { + return buildCellSelectionExportTable({ + columnIds, + range: cellSelectionRange, + rows, + }); + } + + if (selectedRowCount > 0) { + return buildRowSelectionExportTable({ + columnIds, + rowSelectionState, + rows, + }); + } + + return null; + } + + function buildSerializedSelectionExport( + format: SelectionExportFormat, + ): string { + const table = buildSelectionExportTable(); + + if (!table) { + return ""; + } + + return serializeSelectionExport({ + table, + format, + includeColumnHeader, + }); + } + + function handleCopySelectionExport(format: SelectionExportFormat) { + const content = buildSerializedSelectionExport(format); + + setOpen(false); + + if (!content || typeof navigator.clipboard?.writeText !== "function") { + return; + } + + void navigator.clipboard.writeText(content).catch((error) => { + console.error("Failed to copy selection export:", error); + }); + } + + function handleSaveSelectionExport(format: SelectionExportFormat) { + const content = buildSerializedSelectionExport(format); + + setOpen(false); + + if (!content) { + return; + } + + downloadSelectionExport({ + content, + filename: buildSelectionExportFilename({ + base: filenameBase, + format, + }), + format, + }); + } + + return ( + { + setOpen(open); + + if (open) { + setIncludeColumnHeader(true); + } + }} + > + + + + + + setIncludeColumnHeader(checked === true) + } + onSelect={(event) => { + event.preventDefault(); + }} + > + include column header + + +
+ {[ + { + action: () => handleCopySelectionExport("markdown"), + label: "copy markdown", + }, + { + action: () => handleCopySelectionExport("csv"), + label: "copy csv", + }, + { + action: () => handleSaveSelectionExport("markdown"), + label: "save markdown", + }, + { + action: () => handleSaveSelectionExport("csv"), + label: "save csv", + }, + ].map((item) => ( + + {item.label} + + ))} +
+
+
+ ); +} diff --git a/ui/studio/views/table/selection-export.test.ts b/ui/studio/grid/selection-export.test.ts similarity index 73% rename from ui/studio/views/table/selection-export.test.ts rename to ui/studio/grid/selection-export.test.ts index 0046cc40..15bc8510 100644 --- a/ui/studio/views/table/selection-export.test.ts +++ b/ui/studio/grid/selection-export.test.ts @@ -5,6 +5,7 @@ import { buildRowSelectionExportTable, buildSelectionExportFilename, downloadSelectionExport, + getSelectionExportColumnIds, serializeSelectionExport, } from "./selection-export"; @@ -120,18 +121,68 @@ describe("selection-export", () => { it("builds stable filenames for saved exports", () => { expect( buildSelectionExportFilename({ - schema: "public", - table: "users", + base: "public-users", format: "csv", }), ).toBe("public-users-selection.csv"); expect( buildSelectionExportFilename({ - schema: "public", - table: "users", + base: "public-users", format: "markdown", }), ).toBe("public-users-selection.md"); + expect( + buildSelectionExportFilename({ + base: "sql-result", + format: "csv", + }), + ).toBe("sql-result-selection.csv"); + }); + + it("orders export columns by pinning then visible column order", () => { + expect( + getSelectionExportColumnIds({ + columnOrder: ["email", "id", "created_at"], + columnPinning: { + left: ["__ps_select", "created_at"], + right: [], + }, + defaultColumnIds: ["id", "email", "created_at"], + }), + ).toEqual(["created_at", "email", "id"]); + }); + + it("keeps right-pinned columns last, as the grid renders them", () => { + expect( + getSelectionExportColumnIds({ + columnOrder: ["id", "email", "created_at"], + columnPinning: { + left: ["__ps_select", "created_at"], + right: ["id"], + }, + defaultColumnIds: ["id", "email", "created_at"], + }), + ).toEqual(["created_at", "email", "id"]); + }); + + it("never exports the row selection column, whatever the query returns", () => { + expect( + getSelectionExportColumnIds({ + columnOrder: ["__ps_select", "id"], + columnPinning: {}, + defaultColumnIds: ["__ps_select", "id"], + }), + ).toEqual(["id"]); + }); + + it("ignores column order entries that are not part of the result", () => { + expect( + getSelectionExportColumnIds({ + columnOrder: ["dropped_column", "email"], + columnPinning: {}, + defaultColumnIds: ["id", "email"], + }), + ).toEqual(["email", "id"]); }); it("downloads the serialized export via a temporary object url", async () => { diff --git a/ui/studio/views/table/selection-export.ts b/ui/studio/grid/selection-export.ts similarity index 75% rename from ui/studio/views/table/selection-export.ts rename to ui/studio/grid/selection-export.ts index 27d6ef3e..42291cf2 100644 --- a/ui/studio/views/table/selection-export.ts +++ b/ui/studio/grid/selection-export.ts @@ -1,6 +1,11 @@ -import type { RowSelectionState } from "@tanstack/react-table"; +import type { + ColumnPinningState, + RowSelectionState, +} from "@tanstack/react-table"; -import type { GridSelectionRange } from "../../grid/cell-selection"; +import type { GridSelectionRange } from "./cell-selection"; + +const ROW_SELECTION_COLUMN_ID = "__ps_select"; export type SelectionExportFormat = "csv" | "markdown"; @@ -108,13 +113,56 @@ export function serializeSelectionExport(args: { } export function buildSelectionExportFilename(args: { - schema: string; - table: string; + base: string; format: SelectionExportFormat; }): string { const extension = args.format === "csv" ? "csv" : "md"; - return `${args.schema}-${args.table}-selection.${extension}`; + return `${args.base}-selection.${extension}`; +} + +export function getSelectionExportColumnIds(args: { + defaultColumnIds: string[]; + columnOrder: string[]; + columnPinning: ColumnPinningState; +}): string[] { + const { columnOrder, columnPinning, defaultColumnIds } = args; + const validColumnIds = new Set(defaultColumnIds); + const isExportableColumnId = (columnId: string) => + columnId !== ROW_SELECTION_COLUMN_ID && validColumnIds.has(columnId); + const leftPinnedColumnIds = (columnPinning.left ?? []).filter( + isExportableColumnId, + ); + const rightPinnedColumnIds = (columnPinning.right ?? []).filter( + isExportableColumnId, + ); + const pinnedColumnIds = new Set([ + ...leftPinnedColumnIds, + ...rightPinnedColumnIds, + ]); + // Same order as the grid renders: left-pinned, then the visible order, then + // right-pinned. + const orderedColumnIds = [ + ...columnOrder.filter(isExportableColumnId), + ...defaultColumnIds.filter( + (columnId) => + isExportableColumnId(columnId) && !columnOrder.includes(columnId), + ), + ].filter((columnId) => !pinnedColumnIds.has(columnId)); + const seen = new Set(); + + return [ + ...leftPinnedColumnIds, + ...orderedColumnIds, + ...rightPinnedColumnIds, + ].filter((columnId) => { + if (seen.has(columnId)) { + return false; + } + + seen.add(columnId); + return true; + }); } export function downloadSelectionExport(args: { diff --git a/ui/studio/views/sql/SqlView.test.tsx b/ui/studio/views/sql/SqlView.test.tsx index 5355c841..0f18d9ab 100644 --- a/ui/studio/views/sql/SqlView.test.tsx +++ b/ui/studio/views/sql/SqlView.test.tsx @@ -386,6 +386,64 @@ async function waitFor(assertion: () => boolean): Promise { throw new Error("Timed out waiting for SQL view state"); } +function findButtonByText(text: string) { + return Array.from( + document.querySelectorAll("button"), + ).find((button) => button.textContent?.trim() === text); +} + +function findMenuItemByText(text: string) { + return Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.trim() === text); +} + +function dispatchPointerClick(element: Element | null | undefined) { + if (!element) { + return; + } + + const PointerEventConstructor = window.PointerEvent ?? MouseEvent; + + act(() => { + element.dispatchEvent( + new PointerEventConstructor("pointerdown", { + bubbles: true, + button: 0, + cancelable: true, + }), + ); + element.dispatchEvent( + new MouseEvent("click", { bubbles: true, button: 0, cancelable: true }), + ); + }); +} + +function selectResultRow( + container: HTMLElement, + rowIndex: number, + options?: { shiftKey?: boolean }, +) { + const cell = container.querySelector( + `td[data-grid-row-index="${rowIndex}"][data-grid-column-id="__ps_select"]`, + ); + + if (!cell) { + throw new Error(`Could not find row selection cell for row ${rowIndex}`); + } + + act(() => { + cell.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + cancelable: true, + shiftKey: options?.shiftKey ?? false, + }), + ); + }); +} + function renderSqlView() { const container = document.createElement("div"); document.body.appendChild(container); @@ -1710,6 +1768,71 @@ describe("SqlView", () => { harness.cleanup(); }); + it("exports selected SQL result rows through the shared copy-as menu", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText, + }, + }); + + const { adapter } = createAdapterMock({ + raw: () => { + return Promise.resolve([ + null, + { + query: { parameters: [], sql: "select * from users" }, + rowCount: 2, + rows: [ + { email: "alice@example.com", id: "user_1" }, + { email: "bob@example.com", id: "user_2" }, + ], + }, + ]); + }, + }); + const studio = createStudioMock(adapter); + useStudioMock.mockReturnValue(studio); + + const harness = renderSqlView(); + const runButton = [...harness.container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Run SQL"), + ); + + if (!runButton) { + throw new Error("SQL view controls not rendered"); + } + + act(() => { + runButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => { + return ( + harness.container.textContent?.includes("2 row(s) returned") ?? false + ); + }); + + expect(findButtonByText("copy as")).toBeUndefined(); + + selectResultRow(harness.container, 0); + selectResultRow(harness.container, 1, { shiftKey: true }); + await flush(); + + dispatchPointerClick(findButtonByText("copy as")); + await flush(); + + dispatchPointerClick(findMenuItemByText("copy csv")); + + expect(writeText).toHaveBeenCalledWith( + "email,id\nalice@example.com,user_1\nbob@example.com,user_2", + ); + + harness.cleanup(); + }); + it("focuses the SQL editor and places cursor at end on mount", () => { const { adapter } = createAdapterMock(); const studio = createStudioMock(adapter); diff --git a/ui/studio/views/sql/SqlView.tsx b/ui/studio/views/sql/SqlView.tsx index 219f92df..79c70c50 100644 --- a/ui/studio/views/sql/SqlView.tsx +++ b/ui/studio/views/sql/SqlView.tsx @@ -4,6 +4,7 @@ import { Prec } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; import type { ColumnDef, + ColumnPinningState, PaginationState, RowSelectionState, } from "@tanstack/react-table"; @@ -29,6 +30,7 @@ import { TableHead, TableRow } from "../../../components/ui/table"; import { useColumnPinning } from "../../../hooks/use-column-pinning"; import { useIntrospection } from "../../../hooks/use-introspection"; import { useNavigation } from "../../../hooks/use-navigation"; +import { useUiState } from "../../../hooks/use-ui-state"; import type { CellProps } from "../../cell/Cell"; import { Cell } from "../../cell/Cell"; import { getCell } from "../../cell/get-cell"; @@ -36,6 +38,15 @@ import { useStudio } from "../../context"; import { DataGrid, type DataGridProps } from "../../grid/DataGrid"; import { DataGridDraggableHeaderCell } from "../../grid/DataGridDraggableHeaderCell"; import { DataGridHeader } from "../../grid/DataGridHeader"; +import { getSelectionExportColumnIds } from "../../grid/selection-export"; +import { + getCellSelectionRange, + getSelectedRowIds, + GRID_SELECTION_MACHINE_INITIAL_STATE, + type GridSelectionMachineState, + rowIdsToRowSelectionState, +} from "../../grid/selection-state-machine"; +import { SelectionExportMenu } from "../../grid/SelectionExportMenu"; import { StudioHeader } from "../../StudioHeader"; import type { ViewProps } from "../View"; import { resolveAiSqlGeneration } from "./sql-ai-generation"; @@ -88,6 +99,11 @@ const SQL_VIEW_GRID_SCOPE = "sql:view:grid"; const SQL_VIEW_TABLE_NAME = "__sql_result__"; const SQL_VIEW_SCHEMA = "__sql_result__"; const EMPTY_SQL_RESULT_ROWS: Record[] = []; +const EMPTY_SQL_RESULT_COLUMN_ORDER: string[] = []; +const EMPTY_SQL_RESULT_COLUMN_PINNING: ColumnPinningState = { + left: [], + right: [], +}; const MAX_AI_SQL_VALIDATION_CORRECTIONS = 1; const DEFAULT_PAGINATION_STATE: PaginationState = { pageIndex: 0, @@ -116,6 +132,81 @@ const SQL_ROW_SELECTION_COLUMN_DEF = { }, } satisfies ColumnDef>; +function buildSqlGridRows(resultRows: Record[]): SqlGridRow[] { + return resultRows.map((row, index) => { + return { + ...row, + __ps_rowid: `sql-row-${index}`, + }; + }); +} + +function getSqlResultColumnIds( + resultRows: Record[], +): string[] { + const ids: string[] = []; + const seenIds = new Set(); + + for (const row of resultRows) { + for (const key of Object.keys(row)) { + if (seenIds.has(key)) { + continue; + } + + seenIds.add(key); + ids.push(key); + } + } + + return ids; +} + +/** + * Owns the selection subscription so that selecting rows or cells never + * rerenders the SQL editor below the toolbar. + */ +const SqlSelectionExportMenu = memo(function SqlSelectionExportMenu(props: { + resultRows: Record[]; +}) { + const { resultRows } = props; + const [gridSelectionState] = useUiState( + `datagrid:${SQL_VIEW_GRID_SCOPE}:selection-state`, + GRID_SELECTION_MACHINE_INITIAL_STATE, + ); + const [gridColumnOrder] = useUiState( + `datagrid:${SQL_VIEW_GRID_SCOPE}:column-order`, + EMPTY_SQL_RESULT_COLUMN_ORDER, + ); + const [gridColumnPinning] = useUiState( + `datagrid:${SQL_VIEW_GRID_SCOPE}:column-pinning`, + EMPTY_SQL_RESULT_COLUMN_PINNING, + ); + const rows = useMemo(() => buildSqlGridRows(resultRows), [resultRows]); + const columnIds = useMemo( + () => + getSelectionExportColumnIds({ + columnOrder: gridColumnOrder, + columnPinning: gridColumnPinning, + defaultColumnIds: getSqlResultColumnIds(resultRows), + }), + [gridColumnOrder, gridColumnPinning, resultRows], + ); + const rowSelectionState = useMemo( + () => rowIdsToRowSelectionState(getSelectedRowIds(gridSelectionState)), + [gridSelectionState], + ); + + return ( + + ); +}); + interface SqlResultGridProps { isRunning: boolean; paginationState: DataGridProps["paginationState"]; @@ -141,31 +232,14 @@ const SqlResultGrid = memo(function SqlResultGrid(props: SqlResultGridProps) { visualizationState, } = props; const resultRows = useMemo(() => result.rows, [result]); - const rows = useMemo(() => { - return resultRows.map((row, index) => { - return { - ...row, - __ps_rowid: `sql-row-${index}`, - }; - }); - }, [resultRows]); - const resultColumnIds = useMemo(() => { - const ids: string[] = []; - const seenIds = new Set(); - - for (const row of resultRows) { - for (const key of Object.keys(row)) { - if (seenIds.has(key)) { - continue; - } - - seenIds.add(key); - ids.push(key); - } - } - - return ids; - }, [resultRows]); + const rows = useMemo( + () => buildSqlGridRows(resultRows), + [resultRows], + ); + const resultColumnIds = useMemo( + () => getSqlResultColumnIds(resultRows), + [resultRows], + ); const columnMetadataById = useMemo>(() => { const metadata: Record = {}; @@ -1018,10 +1092,18 @@ export function SqlView(_props: ViewProps) { {isRunning ? "Cancel" : "Run SQL"} ); + const sqlToolbarActions = ( + <> + + {runSqlButton} + + ); return (
- + {hasAiSql ? (
0; const deleteSelectionLabel = getDeleteSelectionLabel(selectedRowCount); const deleteConfirmationPrompt = getDeleteConfirmationPrompt(selectedRowCount); @@ -563,12 +545,6 @@ export function ActiveTableView(_props: ViewProps) { isSaveDialogOpen, ]); - useEffect(() => { - if (!hasSelectionExport && isSelectionExportOpen) { - setSelectionExportOpen(false); - } - }, [hasSelectionExport, isSelectionExportOpen]); - const readonly = !Object.values(activeTable?.columns ?? {}).some( (column) => column.pkPosition != null, ); @@ -639,33 +615,6 @@ export function ActiveTableView(_props: ViewProps) { }), [fallbackSelectionExportColumnIds, gridColumnOrder, gridColumnPinning], ); - const selectionExportTable = useMemo(() => { - const exportRows = displayRows; - - if (cellSelectionRange) { - return buildCellSelectionExportTable({ - columnIds: selectionExportColumnIds, - range: cellSelectionRange, - rows: exportRows, - }); - } - - if (selectedRowCount > 0) { - return buildRowSelectionExportTable({ - columnIds: selectionExportColumnIds, - rowSelectionState, - rows: exportRows, - }); - } - - return null; - }, [ - cellSelectionRange, - displayRows, - rowSelectionState, - selectedRowCount, - selectionExportColumnIds, - ]); const editableColumnIds = useMemo( () => getEditableColumnIds(activeTable?.columns, readonly), [activeTable?.columns, readonly], @@ -911,58 +860,6 @@ export function ActiveTableView(_props: ViewProps) { setDeleteDialogOpen(false); } - function buildSerializedSelectionExport( - format: SelectionExportFormat, - ): string { - if (!selectionExportTable) { - return ""; - } - - return serializeSelectionExport({ - table: selectionExportTable, - format, - includeColumnHeader: includeSelectionExportHeader, - }); - } - - function handleCopySelectionExport(format: SelectionExportFormat) { - const content = buildSerializedSelectionExport(format); - - setSelectionExportOpen(false); - - if (!content || typeof navigator.clipboard?.writeText !== "function") { - return; - } - - void navigator.clipboard.writeText(content).catch((error) => { - console.error("Failed to copy selection export:", error); - }); - } - - function handleSaveSelectionExport(format: SelectionExportFormat) { - if (!activeTable) { - return; - } - - const content = buildSerializedSelectionExport(format); - - setSelectionExportOpen(false); - - if (!content) { - return; - } - - downloadSelectionExport({ - content, - filename: buildSelectionExportFilename({ - format, - schema: activeTable.schema, - table: activeTable.name, - }), - format, - }); - } - const commandPaletteActions = useMemo( () => createActiveTableCommandPaletteActions({ @@ -1637,80 +1534,13 @@ export function ActiveTableView(_props: ViewProps) { > Insert row - {hasSelectionExport && ( - { - setSelectionExportOpen(open); - - if (open) { - setIncludeSelectionExportHeader(true); - } - }} - > - - - - - - setIncludeSelectionExportHeader(checked === true) - } - onSelect={(event) => { - event.preventDefault(); - }} - > - include column header - - -
- {[ - { - action: () => handleCopySelectionExport("markdown"), - label: "copy markdown", - }, - { - action: () => handleCopySelectionExport("csv"), - label: "copy csv", - }, - { - action: () => handleSaveSelectionExport("markdown"), - label: "save markdown", - }, - { - action: () => handleSaveSelectionExport("csv"), - label: "save csv", - }, - ].map((item) => ( - - {item.label} - - ))} -
-
-
- )} + {hasStagedChanges && ( <>