From cdbe2963fa1e91743f02248daa439dd573abaecc Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 18 Sep 2026 17:13:44 +0800 Subject: [PATCH] Add DownloadResultsButton to explorer query editor (#50563) ## Context Adds the `DownloadResultsButton` component to the footer of the explorer's query editor - will show up in notebook + query tab image image ## Summary by CodeRabbit - **New Features** - Added a results footer displaying row counts and optional row limits. - Added download and export actions when query results are available. - Standardized the results footer across query and notebook previews. - Added keyboard shortcut hints to export options when shortcuts are enabled. - **Improvements** - Export actions now support read-only result sets without changing displayed output. - Export menu sizing and shortcut labels adapt to the enabled shortcut configuration. - Improved accessibility with a label for refreshing logs. --- .../QueryEditor/QueryResultFooter.tsx | 42 +++++++++++++ .../interfaces/Explorer/QueryEditor/index.tsx | 18 +++--- .../SQLEditor/UtilityPanel/Results.utils.ts | 10 +-- .../Settings/Logs/LogsPreviewer.tsx | 2 +- .../Settings/Logs/PreviewFilterPanel.tsx | 5 +- .../AssistantNotebookPreviewCell.tsx | 23 +++---- .../components/ui/DownloadResultsButton.tsx | 61 ++++++++++++------- 7 files changed, 104 insertions(+), 57 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultFooter.tsx diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultFooter.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultFooter.tsx new file mode 100644 index 00000000000..4aace674c81 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultFooter.tsx @@ -0,0 +1,42 @@ +import { ExplorerQueryFooter } from '../ExplorerQuery' +import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton' +import { pluralize } from '@/lib/helpers' + +interface QueryResultFooterProps { + count: number + rowLimit?: number + results?: readonly Record[] + fileName?: string +} + +export const QueryResultFooter = ({ + count, + rowLimit, + results, + fileName, +}: QueryResultFooterProps) => { + return ( + +
+

+ {count.toLocaleString()} {pluralize(count, 'row')} +

+ {rowLimit && ( + <> +

·

+

{rowLimit < 0 ? 'No row limit' : `Limit ${rowLimit} rows`}

+ + )} +
+ {results && results.length > 0 && ( + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index 1255b081ae2..7e508bddd18 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -18,7 +18,6 @@ import { resolveLogTimeRange } from '../../QuerySources/LogTimeRange.utils' import { ExplorerQuery, ExplorerQueryEditor, - ExplorerQueryFooter, ExplorerQueryResults, ExplorerQueryViewport, } from '../ExplorerQuery' @@ -31,6 +30,7 @@ import { } from '../ExplorerToolbar' import { type QueryDisplay, type QueryResult } from '../types' import { DisplaySettingsButton } from './DisplaySettingsButton' +import { QueryResultFooter } from './QueryResultFooter' import { QueryResultRenderer } from './QueryResultRenderer' import { QueryRunButton } from './QueryRunButton' import { QuerySourceMenu } from './QuerySourceMenu' @@ -190,6 +190,7 @@ export const QueryEditor = forwardRef(funct const columns = Object.keys(result?.rows?.[0] ?? {}) const rowLimit = query._tag === 'database' ? query.rowLimit : undefined const databaseIdentifier = query._tag === 'database' ? query.database_identifier : undefined + const resultsRowCount = (result?.rows ?? []).length const [promptInput, setPromptInput] = useState('') const [pendingRun, setPendingRun] = useState<{ sql: string; issues: PotentialIssues }>() @@ -614,15 +615,12 @@ export const QueryEditor = forwardRef(funct )} - -

{(result?.rows ?? []).length.toLocaleString()} rows

- {rowLimit && ( - <> -

·

-

{rowLimit < 0 ? 'No row limit' : `Limit ${rowLimit} rows`}

- - )} -
+ {query._tag === 'database' && ( diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts index 83bf9212e0e..78772b3fdd3 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts @@ -4,7 +4,7 @@ import Papa from 'papaparse' type ResultRow = Record export function formatResults( - results: ResultRow[] + results: readonly ResultRow[] ): Record[] { return results.map((row) => { const formatted: Record = {} @@ -17,7 +17,7 @@ export function formatResults( }) } -export function convertResultsToMarkdown(results: ResultRow[]): string | undefined { +export function convertResultsToMarkdown(results: readonly ResultRow[]): string | undefined { const formatted = formatResults(results) if (formatted.length === 0) return undefined @@ -32,18 +32,18 @@ export function convertResultsToMarkdown(results: ResultRow[]): string | undefin return markdownTable(table) } -export function convertResultsToJSON(results: ResultRow[]): string | undefined { +export function convertResultsToJSON(results: readonly ResultRow[]): string | undefined { if (results.length === 0) return undefined return JSON.stringify(results, null, 2) } -export function getResultsHeaders(results: ResultRow[]): string[] | undefined { +export function getResultsHeaders(results: readonly ResultRow[]): string[] | undefined { const firstRow = Array.from(results)[0] if (firstRow) return Object.keys(firstRow) return undefined } -export function convertResultsToCSV(results: ResultRow[]): string | undefined { +export function convertResultsToCSV(results: readonly ResultRow[]): string | undefined { if (results.length === 0) return undefined const headers = getResultsHeaders(results) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index 32183f2afa7..9a7990961eb 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -19,7 +19,7 @@ import { calculateBarClickTimeRange } from './LogsBarChart.utils' import { LogTable } from './LogTable' import UpgradePrompt from './UpgradePrompt' import { useLogsPreviewShortcuts } from './useLogsPreviewShortcuts' -import PreviewFilterPanel from '@/components/interfaces/Settings/Logs/PreviewFilterPanel' +import { PreviewFilterPanel } from '@/components/interfaces/Settings/Logs/PreviewFilterPanel' import LoadingOpacity from '@/components/ui/LoadingOpacity' import ShimmerLine from '@/components/ui/ShimmerLine' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' diff --git a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx index 6bf413ffd92..e2bf90d47b9 100644 --- a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx @@ -62,7 +62,7 @@ interface PreviewFilterPanelProps { /** * Logs control panel header + wrapper */ -const PreviewFilterPanel = ({ +export const PreviewFilterPanel = ({ isLoading, newCount, onRefresh, @@ -189,6 +189,7 @@ const PreviewFilterPanel = ({ + {iconOnly ? ( + } + disabled={results.length === 0} + className="w-7" + tooltip={{ content: { side: 'bottom', text: 'Download results' } }} + /> + ) : ( + + )} - + {isLogs && IS_PLATFORM && ( @@ -149,30 +156,38 @@ export const DownloadResultsButton = ({

Copy as Markdown

- - - + {enableCopyShortcuts && ( + + + + )}

Copy as JSON

- - - + {enableCopyShortcuts && ( + + + + )}

Copy as CSV

- - - + {enableCopyShortcuts && ( + + + + )}
downloadAsCSV()}>

Download CSV

- - - + {enableCopyShortcuts && ( + + + + )}