From 302abc5e27ab23c86bf5f986ca2f8c2b07cabc14 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:18:06 -0600 Subject: [PATCH 1/5] feat(studio): SQL Editor IA improvements Refactor the SQL Editor query bar, results panel, and sidebar chrome for clearer run controls, post-run metadata, and consolidated query actions. Includes warehouse routing/lag display in results for prototype demos. --- .../SQLEditor/SQLEditor.constants.ts | 2 +- .../interfaces/SQLEditor/SQLEditor.tsx | 415 +++++++----------- .../SQLEditor/SqlEditorQueryBar.tsx | 145 ++++++ .../SQLEditor/UtilityPanel/RunButton.tsx | 44 +- .../UtilityPanel/SqlEditorLimitSelector.tsx | 58 +++ .../UtilityPanel/SqlResultsSummary.tsx | 39 ++ .../SQLEditor/UtilityPanel/UtilityActions.tsx | 232 ---------- .../SQLEditor/UtilityPanel/UtilityPanel.tsx | 36 +- .../layouts/SQLEditorLayout/SQLEditorMenu.tsx | 172 ++++---- .../SQLEditorNavV2/SQLEditorNav.tsx | 6 +- .../studio/components/ui/DatabaseSelector.tsx | 67 ++- .../state/shortcuts/registry/sql-editor.ts | 7 + 12 files changed, 594 insertions(+), 629 deletions(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlEditorLimitSelector.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx delete mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts index 6d06780a2ae..2738399bd0a 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts @@ -100,7 +100,7 @@ export const ASSISTANT_TEMPLATES = [ ] export const ROWS_PER_PAGE_OPTIONS = [ - { value: -1, label: 'No limit' }, + { value: -1, label: 'None' }, { value: 100, label: '100 rows' }, { value: 500, label: '500 rows' }, { value: 1000, label: '1,000 rows' }, diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 6d1f261f253..75b0f255ee0 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -9,32 +9,17 @@ import { import { wrapWithRollback } from '@supabase/pg-meta/src/query' import { useQueryClient } from '@tanstack/react-query' import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' -import { ChevronUp, Loader2 } from 'lucide-react' +import { Loader2 } from 'lucide-react' import dynamic from 'next/dynamic' import { useRouter } from 'next/router' -import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { - Button, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' import { RunQueryWarningModal } from './RunQueryWarningModal' import { generateSnippetTitle, - ROWS_PER_PAGE_OPTIONS, sqlAiDisclaimerComment, untitledSnippetTitle, } from './SQLEditor.constants' @@ -56,6 +41,7 @@ import { isUpdateWithoutWhere, suffixWithLimit, } from './SQLEditor.utils' +import { SqlEditorQueryBar } from './SqlEditorQueryBar' import { useAddDefinitions } from './useAddDefinitions' import { UtilityPanel } from './UtilityPanel/UtilityPanel' import { @@ -93,7 +79,6 @@ import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' -import { useSqlEditorDiffRequestSnapshot } from '@/state/sql-editor/sql-editor-diff-request' import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' @@ -122,7 +107,6 @@ export const SQLEditor = () => { const { openSidebar } = useSidebarManagerSnapshot() const snapV2 = useSqlEditorV2StateSnapshot() const sessionSnap = useSqlEditorSessionSnapshot() - const diffRequest = useSqlEditorDiffRequestSnapshot() const getImpersonatedRoleState = useGetImpersonatedRoleState() const databaseSelectorState = useDatabaseSelectorStateSnapshot() const { aiOptInLevel } = useOrgAiOptInLevel() @@ -156,9 +140,6 @@ export const SQLEditor = () => { const [potentialIssues, setPotentialIssues] = useState() const [showWidget, setShowWidget] = useState(false) - // Bumped on every editor mount (including the keyed remount on snippet switch) - // so a diff request that arrived before the editor was ready gets re-processed. - const [editorMountCount, setEditorMountCount] = useState(0) const [activeUtilityTab, setActiveUtilityTab] = useState('results') const refocusEditor = useCallback(() => { @@ -205,7 +186,6 @@ export const SQLEditor = () => { const id = !urlId || urlId === 'new' ? generatedId : urlId const limit = sessionSnap.limit - const results = sessionSnap.results[id]?.[0] const snippetIsLoading = !( id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined ) @@ -544,7 +524,7 @@ export const SQLEditor = () => { databaseSelectorState.selectedDatabaseId, databases, lineHighlights, - sessionSnap, + snapV2, ]) useShortcut(SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, executeExplainQuery, { @@ -577,8 +557,6 @@ export const SQLEditor = () => { ) const onMount = (editor: IStandaloneCodeEditor) => { - setEditorMountCount((count) => count + 1) - const tabId = createTabId('sql', { id }) const tabData = tabs.tabsMap[tabId] @@ -834,40 +812,30 @@ export const SQLEditor = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccessReadReplicas, databases, ref]) - const drainDiffRequest = useEffectEvent(() => { - const request = diffRequest.pending - if (request === undefined) return - - const editorModel = editorRef.current?.getModel() - // Editor isn't ready yet; leave the request pending. editorMountCount bumps - // on mount and re-runs this effect, so the request applies once mounted. - if (!editorModel) return - - const { diffType, sql } = request - const existingValue = editorRef.current?.getValue() ?? '' - if (existingValue.length === 0) { - // if the editor is empty, just copy over the code - editorRef.current?.executeEdits('apply-ai-message', [ - { - text: `${sql}`, - range: editorModel.getFullModelRange(), - }, - ]) - } else { - const currentSql = editorRef.current?.getValue() - const diff = { original: currentSql || '', modified: sql } - setSourceSqlDiff(diff) - setSelectedDiffType(diffType) - } - - // One-shot: drain the request so it can't re-apply to a later editor or session. - diffRequest.consumeDiffRequest() - }) useEffect(() => { - drainDiffRequest() - // until we can upgrade eslint to ignore useEffectEvent + if (snapV2.diffContent !== undefined) { + const { diffType, sql }: { diffType: DiffType; sql: string } = snapV2.diffContent + const editorModel = editorRef.current?.getModel() + if (!editorModel) return + + const existingValue = editorRef.current?.getValue() ?? '' + if (existingValue.length === 0) { + // if the editor is empty, just copy over the code + editorRef.current?.executeEdits('apply-ai-message', [ + { + text: `${sql}`, + range: editorModel.getFullModelRange(), + }, + ]) + } else { + const currentSql = editorRef.current?.getValue() + const diff = { original: currentSql || '', modified: sql } + setSourceSqlDiff(diff) + setSelectedDiffType(diffType) + } + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [diffRequest.pending, editorMountCount]) + }, [snapV2.diffContent]) // We want to check if the diff editor is mounted and if it is, we want to show the widget // We also want to cleanup the widget when the diff editor is closed @@ -881,6 +849,121 @@ export const SQLEditor = () => { } }, [isDiffOpen, isDiffEditorMounted]) + const queryResult = sessionSnap.results[id]?.[0] + const explainResult = sessionSnap.explainResults[id] + const hasExplainResult = + explainResult?.error !== undefined || (explainResult?.rows?.length ?? 0) > 0 + const showUtilityPanel = + isExecuting || isExplainExecuting || queryResult !== undefined || hasExplainResult + + const sqlEditorWorkspace = isLoading ? ( +
+ +
+ ) : ( + <> + {isDiffOpen && ( +
+ { + diffEditorRef.current = editor + setIsDiffEditorMounted(true) + }} + /> + {showWidget && ( + { + handlePrompt(prompt, { + beforeSelection: promptState.beforeSelection, + selection: promptState.selection || defaultSqlDiff.modified, + afterSelection: promptState.afterSelection, + }) + }} + onAccept={acceptAiHandler} + onReject={discardAiHandler} + onCancel={resetPrompt} + isDiffVisible={true} + isLoading={isCompletionLoading} + startLineNumber={Math.max(0, promptState.startLineNumber)} + endLineNumber={promptState.endLineNumber} + /> + )} +
+ )} +
+ { + setPromptState((prev) => ({ + ...prev, + isOpen: true, + selection, + beforeSelection, + afterSelection, + startLineNumber, + endLineNumber, + })) + }} + /> + {editorRef.current && promptState.isOpen && !isDiffOpen && ( + { + handlePrompt(prompt, { + beforeSelection: promptState.beforeSelection, + selection: promptState.selection, + afterSelection: promptState.afterSelection, + }) + }} + onCancel={resetPrompt} + isDiffVisible={false} + isLoading={isCompletionLoading} + startLineNumber={Math.max(0, promptState.startLineNumber)} + endLineNumber={promptState.endLineNumber} + /> + )} +
+ + ) + return ( <> { }} /> -
- - -
- {isLoading ? ( -
- -
- ) : ( - <> - {isDiffOpen && ( -
- { - diffEditorRef.current = editor - setIsDiffEditorMounted(true) - }} - /> - {showWidget && ( - { - handlePrompt(prompt, { - beforeSelection: promptState.beforeSelection, - selection: promptState.selection || defaultSqlDiff.modified, - afterSelection: promptState.afterSelection, - }) - }} - onAccept={acceptAiHandler} - onReject={discardAiHandler} - onCancel={resetPrompt} - isDiffVisible={true} - isLoading={isCompletionLoading} - startLineNumber={Math.max(0, promptState.startLineNumber)} - endLineNumber={promptState.endLineNumber} - /> - )} -
- )} -
- { - setPromptState((prev) => ({ - ...prev, - isOpen: true, - selection, - beforeSelection, - afterSelection, - startLineNumber, - endLineNumber, - })) - }} - /> - {editorRef.current && promptState.isOpen && !isDiffOpen && ( - { - handlePrompt(prompt, { - beforeSelection: promptState.beforeSelection, - selection: promptState.selection, - afterSelection: promptState.afterSelection, - }) - }} - onCancel={resetPrompt} - isDiffVisible={false} - isLoading={isCompletionLoading} - startLineNumber={Math.max(0, promptState.startLineNumber)} - endLineNumber={promptState.endLineNumber} - /> - )} -
- - )} -
-
+
+ {!isLoading && ( + + )} + {showUtilityPanel ? ( + + +
{sqlEditorWorkspace}
+
- + - - {isLoading ? ( -
- -
- ) : ( + { activeTab={activeUtilityTab} onActiveTabChange={setActiveUtilityTab} /> - )} - - -
- {results?.rows !== undefined && !isExecuting && ( - - - -

- - {results.rows.length} row{results.rows.length > 1 ? 's' : ''} - - - {results.autoLimit !== undefined && - ` (Limited to only ${results.autoLimit} rows)`} - -

-
- -

- - Results are automatically limited to preserve browser performance, in - particular if your query returns an exceptionally large number of rows. - - - - You may change or remove this limit from the dropdown on the right - -

-
-
- {results.autoLimit !== undefined && ( - - - - - - sessionSnap.setLimit(Number(val))} - > - {ROWS_PER_PAGE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - )} -
- )} -
-
+ + + ) : ( +
{sqlEditorWorkspace}
+ )}
) diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx new file mode 100644 index 00000000000..c789b5f78ce --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx @@ -0,0 +1,145 @@ +import { Hotkey } from '@tanstack/react-hotkeys' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react' +import { toast } from 'sonner' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + KeyboardShortcut, +} from 'ui' + +import { SqlRunButton } from './UtilityPanel/RunButton' +import SavingIndicator from './UtilityPanel/SavingIndicator' +import { SqlEditorLimitSelector } from './UtilityPanel/SqlEditorLimitSelector' +import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' +import { DatabaseSelector } from '@/components/ui/DatabaseSelector' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { IS_PLATFORM } from '@/lib/constants' +import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' +import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' + +export type SqlEditorQueryBarProps = { + id: string + isExecuting?: boolean + isDisabled?: boolean + hasSelection?: boolean + prettifyQuery: () => void + executeQuery: () => void +} + +export function SqlEditorQueryBar({ + id, + isExecuting = false, + isDisabled = false, + hasSelection = false, + prettifyQuery, + executeQuery, +}: SqlEditorQueryBarProps) { + const { ref } = useParams() + const snapV2 = useSqlEditorV2StateSnapshot() + + const [intelliSenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, + true + ) + const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string), + '' + ) + + const snippet = snapV2.snippets[id] + const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false + + const hotkeySequnece: Hotkey | undefined = + SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0] + const formatKeys = hotkeySequnece ? hotkeyToKeys(hotkeySequnece) : undefined + + const toggleIntellisense = () => { + setIntellisenseEnabled(!intelliSenseEnabled) + toast.success( + `Successfully ${intelliSenseEnabled ? 'disabled' : 'enabled'} intellisense. ${intelliSenseEnabled ? 'Please refresh your browser for changes to take place.' : ''}` + ) + } + + const toggleFavorite = () => { + if (isFavorite) snapV2.removeFavorite(id) + else snapV2.addFavorite(id) + } + + const onSelectDatabase = (databaseId: string) => { + snapV2.resetResults(id) + setLastSelectedDb(databaseId) + } + + return ( +
+
{IS_PLATFORM && }
+ +
+ + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx index 861481f457d..02c901c61c7 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx @@ -1,5 +1,8 @@ -import { Loader2 } from 'lucide-react' -import { Button, KeyboardShortcut } from 'ui' +import { CirclePlay, Loader2 } from 'lucide-react' +import { Button } from 'ui' + +import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' interface SqlRunButtonProps { isDisabled?: boolean @@ -17,22 +20,27 @@ export const SqlRunButton = ({ onClick, }: SqlRunButtonProps) => { return ( - + + ) } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlEditorLimitSelector.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlEditorLimitSelector.tsx new file mode 100644 index 00000000000..e439bb4c7ff --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlEditorLimitSelector.tsx @@ -0,0 +1,58 @@ +import { ChevronDown } from 'lucide-react' +import { + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from 'ui' + +import { ROWS_PER_PAGE_OPTIONS } from '@/components/interfaces/SQLEditor/SQLEditor.constants' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' + +interface SqlEditorLimitSelectorProps { + variant?: 'regular' | 'connected-on-right' | 'connected-on-both' +} + +export function SqlEditorLimitSelector({ variant = 'regular' }: SqlEditorLimitSelectorProps) { + const sessionSnap = useSqlEditorSessionSnapshot() + const selectedLabel = + ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === sessionSnap.limit)?.label ?? '100 rows' + + return ( + + + + + + sessionSnap.setLimit(Number(val))} + > + {ROWS_PER_PAGE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx new file mode 100644 index 00000000000..4a4a818e92e --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx @@ -0,0 +1,39 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +interface SqlResultsSummaryProps { + rowCount: number + autoLimit?: number +} + +export function SqlResultsSummary({ rowCount, autoLimit }: SqlResultsSummaryProps) { + return ( +

+ {autoLimit !== undefined ? ( + + + + {rowCount} row{rowCount > 1 ? 's' : ''} + + {` (Limited to only ${autoLimit} rows)`} + + + + +

+ + Results are limited to preserve browser performance when a query returns many rows. + + + Change or remove this limit from the dropdown near Run. + +

+ + + ) : ( + + {rowCount} row{rowCount > 1 ? 's' : ''} + + )} +

+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx deleted file mode 100644 index 58b7f93131f..00000000000 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { Hotkey } from '@tanstack/react-hotkeys' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' -import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react' -import { toast } from 'sonner' -import { - Button, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - KeyboardShortcut, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' - -import { SqlRunButton } from './RunButton' -import SavingIndicator from './SavingIndicator' -import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' -import { DatabaseSelector } from '@/components/ui/DatabaseSelector' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { IS_PLATFORM } from '@/lib/constants' -import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' -import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' -import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' - -export type UtilityActionsProps = { - id: string - isExecuting?: boolean - isDisabled?: boolean - hasSelection?: boolean - prettifyQuery: () => void - executeQuery: () => void -} - -export const UtilityActions = ({ - id, - isExecuting = false, - isDisabled = false, - hasSelection = false, - prettifyQuery, - executeQuery, -}: UtilityActionsProps) => { - const { ref } = useParams() - const snapV2 = useSqlEditorV2StateSnapshot() - const sessionSnap = useSqlEditorSessionSnapshot() - - const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true) - const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, - true - ) - const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string), - '' - ) - - const snippet = snapV2.snippets[id] - const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false - - const hotkeySequnece: Hotkey | undefined = - SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0] - const formatKeys = hotkeySequnece ? hotkeyToKeys(hotkeySequnece) : undefined - - const toggleIntellisense = () => { - setIntellisenseEnabled(!intellisenseEnabled) - toast.success( - `Successfully ${intellisenseEnabled ? 'disabled' : 'enabled'} intellisense. ${intellisenseEnabled ? 'Please refresh your browser for changes to take place.' : ''}` - ) - } - - const addFavorite = () => snapV2.addFavorite(id) - - const removeFavorite = () => snapV2.removeFavorite(id) - - const onSelectDatabase = (databaseId: string) => { - sessionSnap.resetResults(id) - setLastSelectedDb(databaseId) - } - - return ( -
- {IS_PLATFORM && } - - - -
- -
-
- {IS_PLATFORM && ( - - )} - - -
-
-
- ) -} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index 98af2875443..4c55266e376 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' import { ChartConfig } from './ChartConfig' -import { UtilityActions } from './UtilityActions' +import { SqlResultsSummary } from './SqlResultsSummary' import { UtilityTabExplain } from './UtilityTabExplain' import { UtilityTabResults } from './UtilityTabResults' import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton' @@ -19,9 +19,6 @@ export type UtilityPanelProps = { isExplainExecuting?: boolean isDebugging?: boolean isDisabled?: boolean - hasSelection: boolean - prettifyQuery: () => void - executeQuery: () => void executeExplainQuery: () => void showExplainTab?: boolean onDebug: () => void @@ -45,9 +42,6 @@ export const UtilityPanel = ({ isExplainExecuting, isDebugging, isDisabled, - hasSelection, - prettifyQuery, - executeQuery, executeExplainQuery, showExplainTab = true, onDebug, @@ -62,6 +56,11 @@ export const UtilityPanel = ({ const snippet = snapV2.snippets[id]?.snippet const result = sessionSnap.results[id]?.[0] + const showResultsSummary = + (activeTab === 'results' || activeTab === 'chart') && + result?.rows !== undefined && + !isExecuting && + !result.error const handleTabChange = (tab: string) => { // When switching to the explain tab, trigger the explain query @@ -135,8 +134,8 @@ export const UtilityPanel = ({ onValueChange={handleTabChange} className="w-full h-full flex flex-col" > - -
+
+ Results @@ -148,12 +147,18 @@ export const UtilityPanel = ({ Chart + + +
+ {showResultsSummary && ( + + )} {result?.rows && ( track('sql_editor_result_download_csv_clicked')} onCopyAsMarkdown={() => track('sql_editor_result_copy_markdown_clicked')} onCopyAsJSON={() => track('sql_editor_result_copy_json_clicked')} @@ -161,16 +166,7 @@ export const UtilityPanel = ({ /> )}
- - - +
{ 'inserted_at' ) - const appState = getAppStateSnapshot() const debouncedSearch = useDebounce(search, 500) const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( @@ -80,95 +79,100 @@ export const SQLEditorMenu = () => { } } + const openRunningQueries = () => { + getAppStateSnapshot().setOnGoingQueriesPanelOpen(true) + } + useEffect(() => { setShowSearch(debouncedSearch.length > 0) }, [debouncedSearch]) return ( -
-
-
- - { - const value = e.target.value - setSearch(value) - if (value.length === 0) setShowSearch(false) - }} - onKeyDown={(e) => { - if (e.code === 'Escape') { - setSearch('') - setShowSearch(false) - } - }} - > - {showSearch ? ( - - { - setSearch('') - setShowSearch(false) - }} - > - - - Clear search - - ) : ( - setSort(value)} - > - - Alphabetical - - - Created At - - - )} - - - - - - -
- - {showSearch ? : } +
+
+ + + +
-
- +
+ + { + const value = e.target.value + setSearch(value) + if (value.length === 0) setShowSearch(false) + }} + onKeyDown={(e) => { + if (e.code === 'Escape') { + setSearch('') + setShowSearch(false) + } + }} + > + {showSearch ? ( + + { + setSearch('') + setShowSearch(false) + }} + > + + + Clear search + + ) : ( + setSort(value)} + > + + Alphabetical + + + Created At + + + )} + + + + {showSearch ? : }
) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 0d574cf6c1f..3d401883e78 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -1,6 +1,5 @@ import { keepPreviousData } from '@tanstack/react-query' import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' -import { Heart } from 'lucide-react' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -559,9 +558,8 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { className="mx-2 px-3" description={ <> - Save a query to favorites for easy accessibility by clicking the{' '} - {' '} - icon. + Save a query to favorites from the query options menu in the editor, or by + right-clicking a query in the sidebar. } /> diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index 0543f8f87dd..01788bdbf0c 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -80,15 +80,27 @@ export const DatabaseSelector = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [_selectedDatabaseId]) + const selectedDatabaseLabel = selectedAdditionalOption ? ( + {selectedAdditionalOption.name} + ) : ( + <> + + {isLoading || selectedDatabase?.identifier === projectRef + ? 'Primary database' + : 'Read replica'} + + {isSuccess && selectedDatabase?.identifier !== projectRef && ( + + ({selectedDatabaseRegion} - {formattedDatabaseId}) + + )} + + ) + return ( -
- {!isForm && ( - - Source - - )} + {isForm ? ( -
+ ) : ( + + )}
diff --git a/apps/studio/state/shortcuts/registry/sql-editor.ts b/apps/studio/state/shortcuts/registry/sql-editor.ts index c2d91122ed9..e6409505516 100644 --- a/apps/studio/state/shortcuts/registry/sql-editor.ts +++ b/apps/studio/state/shortcuts/registry/sql-editor.ts @@ -6,6 +6,7 @@ export const SQL_EDITOR_SHORTCUT_IDS = { SQL_EDITOR_FORMAT: 'sql-editor.format', SQL_EDITOR_EXPLAIN: 'sql-editor.explain', SQL_EDITOR_NEW_SNIPPET: 'sql-editor.new-snippet', + SQL_EDITOR_RUN: 'sql-editor.run', } export type SqlEditorShortcutId = @@ -42,4 +43,10 @@ export const sqlEditorRegistry: RegistryDefinations = { sequence: ['Shift+N'], showInSettings: false, }, + [SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_RUN]: { + id: SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_RUN, + label: 'Run query', + sequence: ['Mod+Enter'], + showInSettings: false, + }, } From 3e73fca90803e5de5e2deef670d8f6520023a625 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:09:35 -0600 Subject: [PATCH 2/5] search path selector --- .../SQLEditor/SqlEditorQueryBar.tsx | 2 + .../SQLEditor/SqlEditorSearchPathSelector.tsx | 123 ++++++++++++++++++ packages/common/constants/local-storage.ts | 1 + 3 files changed, 126 insertions(+) create mode 100644 apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx index 4a949d4f637..11c63f02162 100644 --- a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx @@ -12,6 +12,7 @@ import { KeyboardShortcut, } from 'ui' +import { SqlEditorSearchPathSelector } from './SqlEditorSearchPathSelector' import { SqlRunButton } from './UtilityPanel/RunButton' import SavingIndicator from './UtilityPanel/SavingIndicator' import { SqlEditorLimitSelector } from './UtilityPanel/SqlEditorLimitSelector' @@ -133,6 +134,7 @@ export function SqlEditorQueryBar({ onSelectId={onSelectDatabase} /> )} + option.value === value) ?? + SEARCH_PATH_OPTIONS.find((option) => option.value === 'custom')! + ) +} + +export function SqlEditorSearchPathSelector() { + const { ref } = useParams() + const [searchPath, setSearchPath] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SQL_EDITOR_SEARCH_PATH(ref ?? 'unknown'), + DEFAULT_SEARCH_PATH + ) + const selectedOption = getSearchPathOption(searchPath) + const isWarehousePath = selectedOption.value.endsWith('_warehouse') + + return ( + + + + + + + Search path + + Unqualified table names resolve against this schema first. Custom represents a + multi-schema path. + + + + + {SEARCH_PATH_OPTIONS.map((option) => ( + +
+ + {option.label} + + + {option.description} + +
+
+ ))} +
+
+
+ ) +} diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index b31810f31eb..6352a8d548a 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -45,6 +45,7 @@ export const LOCAL_STORAGE_KEYS = { SQL_EDITOR_AI_SCHEMA: (ref: string) => `supabase_sql-editor-ai-schema-enabled-${ref}`, SQL_EDITOR_AI_OPEN: 'supabase_sql-editor-ai-open', SQL_EDITOR_LAST_SELECTED_DB: (ref: string) => `sql-editor-last-selected-db-${ref}`, + SQL_EDITOR_SEARCH_PATH: (ref: string) => `sql-editor-search-path-${ref}`, SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`, SQL_EDITOR_SECTION_STATE: (ref: string) => `sql-editor-section-state-${ref}`, SQL_EDITOR_SORT: (ref: string) => `sql-editor-sort-${ref}`, From 0f37d29f6302e2139f3318af966d9d0ca269e1cd Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:27:56 -0600 Subject: [PATCH 3/5] polish --- .../RoleImpersonationPopover.tsx | 5 +- .../interfaces/SQLEditor/SQLEditor.tsx | 17 +- .../SQLEditor/SqlEditorQueryBar.tsx | 253 +++++++++++++++--- .../SQLEditor/SqlEditorSearchPathSelector.tsx | 184 +++++++++---- .../SQLEditor/SqlEditorWarehouseDemo.tsx | 114 ++++++++ .../UtilityPanel/SqlResultsSummary.tsx | 70 ++--- .../SQLEditor/UtilityPanel/UtilityPanel.tsx | 8 +- .../components/interfaces/UserDropdown.tsx | 2 + .../SqlEditorWarehouseDemoMenu.tsx | 89 ++++++ .../studio/components/ui/DatabaseSelector.tsx | 10 +- apps/studio/data/sql/execute-sql-mutation.ts | 2 + .../sql-editor-session-state.test.ts | 13 + .../sql-editor/sql-editor-session-state.ts | 32 ++- 13 files changed, 653 insertions(+), 146 deletions(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx create mode 100644 apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx index 27d10d83561..724232ae8c8 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx @@ -13,6 +13,7 @@ export interface RoleImpersonationPopoverProps { variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both' align?: 'center' | 'start' | 'end' disallowAuthenticatedOption?: boolean + className?: string } export const RoleImpersonationPopover = ({ @@ -21,6 +22,7 @@ export const RoleImpersonationPopover = ({ variant = 'regular', align = 'end', disallowAuthenticatedOption = false, + className, }: RoleImpersonationPopoverProps) => { const state = useRoleImpersonationStateSnapshot() @@ -38,7 +40,8 @@ export const RoleImpersonationPopover = ({ 'h-[26px] pr-3 gap-0', variant === 'connected-on-right' && 'rounded-r-none border-r-0', variant === 'connected-on-left' && 'rounded-l-none border-l-0', - variant === 'connected-on-both' && 'rounded-none border-x-0' + variant === 'connected-on-both' && 'rounded-none border-x-0', + className )} >
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index c9d8c36ce53..91364df5aa2 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -42,6 +42,11 @@ import { suffixWithLimit, } from './SQLEditor.utils' import { SqlEditorQueryBar } from './SqlEditorQueryBar' +import { + DEFAULT_SQL_EDITOR_SEARCH_PATH, + getSqlEditorSearchPathStorageKey, +} from './SqlEditorSearchPathSelector' +import { resolveSqlWarehouseResultSource } from './SqlEditorWarehouseDemo' import { useAddDefinitions } from './useAddDefinitions' import { UtilityPanel } from './UtilityPanel/UtilityPanel' import { @@ -58,6 +63,7 @@ import { lintKeys } from '@/data/lint/keys' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { isError } from '@/data/utils/error-check' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -112,6 +118,10 @@ export const SQLEditor = () => { const sessionSnap = useSqlEditorSessionSnapshot() const diffRequest = useSqlEditorDiffRequestSnapshot() const getImpersonatedRoleState = useGetImpersonatedRoleState() + const [searchPath] = useLocalStorageQuery( + getSqlEditorSearchPathStorageKey(ref ?? 'unknown'), + DEFAULT_SQL_EDITOR_SEARCH_PATH + ) const databaseSelectorState = useDatabaseSelectorStateSnapshot() const { aiOptInLevel } = useOrgAiOptInLevel() @@ -220,7 +230,7 @@ export const SQLEditor = () => { const { mutate: execute, isPending: isExecuting } = useExecuteSqlMutation({ onSuccess(data, vars) { if (id) { - sessionSnap.addResult(id, data.result, vars.autoLimit) + sessionSnap.addResult(id, data.result, vars.autoLimit, vars.warehouseResultSource) if (!disablePrettyExplain && isExplainQuery(data.result)) { sessionSnap.addExplainResult(id, data.result) @@ -268,7 +278,7 @@ export const SQLEditor = () => { } } - sessionSnap.addResultError(id, error, vars.autoLimit) + sessionSnap.addResultError(id, error, vars.autoLimit, vars.warehouseResultSource) } refocusEditorAfterRunIfNeeded() @@ -418,12 +428,14 @@ export const SQLEditor = () => { const userSql = rawSql(sql) const { appendAutoLimit } = checkIfAppendLimitRequired(userSql, limit) const formattedSql = suffixWithLimit(userSql, limit) + const warehouseResultSource = resolveSqlWarehouseResultSource(userSql, searchPath) execute({ projectRef: project.ref, connectionString: connectionString, sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState), autoLimit: appendAutoLimit ? limit : undefined, + warehouseResultSource, isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), isStatementTimeoutDisabled: true, contextualInvalidation: true, @@ -449,6 +461,7 @@ export const SQLEditor = () => { databases, eventTriggers, limit, + searchPath, track, ] ) diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx index 11c63f02162..556ae71b56c 100644 --- a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx @@ -1,18 +1,30 @@ import { Hotkey } from '@tanstack/react-hotkeys' import { LOCAL_STORAGE_KEYS, useParams } from 'common' -import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react' +import { AlignLeft, ChevronDown, MoreVertical } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Button, DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, KeyboardShortcut, + Popover, + PopoverContent, + PopoverTrigger, } from 'ui' +import { useSnapshot } from 'valtio' -import { SqlEditorSearchPathSelector } from './SqlEditorSearchPathSelector' +import { + DEFAULT_SQL_EDITOR_SEARCH_PATH, + getSqlEditorSearchPathStorageKey, + SqlEditorSearchPathSelector, +} from './SqlEditorSearchPathSelector' +import { sqlEditorWarehouseDemoStore } from './SqlEditorWarehouseDemo' import { SqlRunButton } from './UtilityPanel/RunButton' import SavingIndicator from './UtilityPanel/SavingIndicator' import { SqlEditorLimitSelector } from './UtilityPanel/SqlEditorLimitSelector' @@ -20,6 +32,7 @@ import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonat import { DatabaseSelector } from '@/components/ui/DatabaseSelector' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { IS_PLATFORM } from '@/lib/constants' +import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' @@ -34,6 +47,8 @@ export type SqlEditorQueryBarProps = { executeQuery: () => void } +const SEPARATE_QUERY_CONTROLS_INLINE_MIN_WIDTH = 896 + export function SqlEditorQueryBar({ id, isExecuting = false, @@ -45,6 +60,10 @@ export function SqlEditorQueryBar({ const { ref } = useParams() const snapV2 = useSqlEditorV2StateSnapshot() const sessionSnap = useSqlEditorSessionSnapshot() + const roleImpersonationState = useRoleImpersonationStateSnapshot() + const warehouseDemoSnap = useSnapshot(sqlEditorWarehouseDemoStore) + const queryBarRef = useRef(null) + const [areSeparateQueryControlsCollapsed, setAreSeparateQueryControlsCollapsed] = useState(false) const [intelliSenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, @@ -54,24 +73,34 @@ export function SqlEditorQueryBar({ LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string), '' ) + const [searchPath] = useLocalStorageQuery( + getSqlEditorSearchPathStorageKey(ref ?? 'unknown'), + DEFAULT_SQL_EDITOR_SEARCH_PATH + ) const snippet = snapV2.snippets[id] const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false + const currentRole = roleImpersonationState.role?.role ?? 'postgres' const hotkeySequnece: Hotkey | undefined = SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0] const formatKeys = hotkeySequnece ? hotkeyToKeys(hotkeySequnece) : undefined - const toggleIntellisense = () => { - setIntellisenseEnabled(!intelliSenseEnabled) + const handleIntelliSenseChange = (checked: boolean) => { + setIntellisenseEnabled(checked) toast.success( - `Successfully ${intelliSenseEnabled ? 'disabled' : 'enabled'} intellisense. ${intelliSenseEnabled ? 'Please refresh your browser for changes to take place.' : ''}` + `Successfully ${checked ? 'enabled' : 'disabled'} IntelliSense. ${checked ? '' : 'Please refresh your browser for changes to take place.'}` ) } const toggleFavorite = () => { - if (isFavorite) snapV2.removeFavorite(id) - else snapV2.addFavorite(id) + if (isFavorite) { + snapV2.removeFavorite(id) + toast.success('Removed from favourites') + } else { + snapV2.addFavorite(id) + toast.success('Added to favourites') + } } const onSelectDatabase = (databaseId: string) => { @@ -79,8 +108,36 @@ export function SqlEditorQueryBar({ setLastSelectedDb(databaseId) } + useEffect(() => { + const queryBar = queryBarRef.current + if (!queryBar) return + + const updateQueryControlsVisibility = (width: number) => { + const shouldCollapse = width < SEPARATE_QUERY_CONTROLS_INLINE_MIN_WIDTH + setAreSeparateQueryControlsCollapsed((current) => + current === shouldCollapse ? current : shouldCollapse + ) + } + + updateQueryControlsVisibility(queryBar.getBoundingClientRect().width) + + const observer = new ResizeObserver(([entry]) => { + updateQueryControlsVisibility(entry.contentRect.width) + }) + observer.observe(queryBar) + + return () => observer.disconnect() + }, []) + + const isSeparateQueryControlMode = warehouseDemoSnap.queryControlMode === 'separate' + const shouldShowCollapsedQueryControls = + isSeparateQueryControlMode && areSeparateQueryControlsCollapsed + return ( -
+
{IS_PLATFORM && }
@@ -94,7 +151,23 @@ export function SqlEditorQueryBar({ aria-label="Query options" /> - + + {shouldShowCollapsedQueryControls && ( + <> +
+

Query context

+ +
+ + + )} + + Actions + @@ -102,40 +175,39 @@ export function SqlEditorQueryBar({ {formatKeys && } - {IS_PLATFORM && ( - <> - - - - {isFavorite ? 'Remove from' : 'Add to'} favorites - - - )} - - - - IntelliSense enabled - - {intelliSenseEnabled && } - + + Preferences + + {IS_PLATFORM && ( + + Favourite query + + )} + + IntelliSense +
- {IS_PLATFORM && ( - + +
+ ) : ( + )} - - ) } + +type QueryContextDropdownProps = { + currentRole: string + onSelectDatabase: (databaseId: string) => void + searchPath: string + selectedDatabaseId: string +} + +type SeparateQueryControlsProps = { + fullWidth?: boolean + onSelectDatabase: (databaseId: string) => void + selectedDatabaseId: string +} + +function SeparateQueryControls({ + fullWidth = false, + onSelectDatabase, + selectedDatabaseId, +}: SeparateQueryControlsProps) { + const controlClassName = fullWidth ? 'w-full [&>span]:w-full' : undefined + + return ( + <> + {IS_PLATFORM && ( + + )} + + span]:w-full' : undefined} + /> + + ) +} + +function QueryContextDropdown({ + currentRole, + onSelectDatabase, + searchPath, + selectedDatabaseId, +}: QueryContextDropdownProps) { + const { ref } = useParams() + const isPrimaryConnection = selectedDatabaseId.length === 0 || selectedDatabaseId === ref + const selectedConnectionLabel = isPrimaryConnection ? 'Primary' : 'Replica' + const activeContextLabels = [ + !isPrimaryConnection ? selectedConnectionLabel : undefined, + searchPath !== DEFAULT_SQL_EDITOR_SEARCH_PATH ? searchPath : undefined, + currentRole !== 'postgres' ? currentRole : undefined, + ].filter((label): label is string => label !== undefined) + const contextSummary = + activeContextLabels.length === 0 + ? undefined + : activeContextLabels.length === 1 + ? activeContextLabels[0] + : `${activeContextLabels.length} changes` + + return ( + + + + + +
+

Query context

+

+ These settings apply together when this query runs. +

+
+
+ {IS_PLATFORM && ( + + )} + + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx index fcce7d7d335..7d7e9961625 100644 --- a/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx @@ -1,10 +1,12 @@ -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useParams } from 'common' import { ChevronDown } from 'lucide-react' +import { useState } from 'react' import { Button, cn, DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, @@ -12,59 +14,111 @@ import { DropdownMenuTrigger, } from 'ui' +import { useSchemasQuery } from '@/data/database/schemas-query' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' type SearchPathOption = { value: string label: string - description: string + description?: string +} + +type SqlEditorSearchPathSelectorProps = { + className?: string + align?: 'start' | 'end' } const SEARCH_PATH_OPTIONS: SearchPathOption[] = [ { value: 'public', label: 'public', - description: 'Default Postgres tables', }, { value: 'public_warehouse', label: 'public_warehouse', - description: 'Linked Warehouse tables', + description: 'Warehouse linked schema', }, { value: 'auth', label: 'auth', - description: 'Auth schema', }, { value: 'storage', label: 'storage', - description: 'Storage schema', - }, - { - value: 'custom', - label: 'Custom', - description: 'Multiple schemas, e.g. "$user", public', }, ] -const DEFAULT_SEARCH_PATH = 'public' +export const DEFAULT_SQL_EDITOR_SEARCH_PATH = 'public' +const COMMON_SCHEMA_NAMES = ['public', 'auth', 'storage'] +export const getSqlEditorSearchPathStorageKey = (ref: string) => `sql-editor-search-path-${ref}` -function getSearchPathOption(value: string) { +function getSchemaDescription(schemaName: string) { + if (schemaName.endsWith('_warehouse')) return 'Warehouse linked schema' + + return undefined +} + +function getSchemaSortPriority(schemaName: string) { + const commonIndex = COMMON_SCHEMA_NAMES.indexOf(schemaName) + if (commonIndex !== -1) return commonIndex + if (schemaName.endsWith('_warehouse')) return COMMON_SCHEMA_NAMES.length + + return COMMON_SCHEMA_NAMES.length + 1 +} + +function getSearchPathOption(value: string, options: SearchPathOption[]) { return ( - SEARCH_PATH_OPTIONS.find((option) => option.value === value) ?? - SEARCH_PATH_OPTIONS.find((option) => option.value === 'custom')! + options.find((option) => option.value === value) ?? { + value, + label: value, + } ) } -export function SqlEditorSearchPathSelector() { +export function SqlEditorSearchPathSelector({ + className, + align = 'end', +}: SqlEditorSearchPathSelectorProps) { const { ref } = useParams() + const [isShowingMoreSchemas, setIsShowingMoreSchemas] = useState(false) + const { data: project } = useSelectedProjectQuery() + const { data: schemaData } = useSchemasQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) const [searchPath, setSearchPath] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.SQL_EDITOR_SEARCH_PATH(ref ?? 'unknown'), - DEFAULT_SEARCH_PATH + getSqlEditorSearchPathStorageKey(ref ?? 'unknown'), + DEFAULT_SQL_EDITOR_SEARCH_PATH ) - const selectedOption = getSearchPathOption(searchPath) - const isWarehousePath = selectedOption.value.endsWith('_warehouse') + const schemaOptions = + schemaData !== undefined && schemaData.length > 0 + ? schemaData + .map((schema) => ({ + value: schema.name, + label: schema.name, + description: getSchemaDescription(schema.name), + })) + .sort((a, b) => { + const priority = getSchemaSortPriority(a.label) - getSchemaSortPriority(b.label) + return priority !== 0 ? priority : a.label.localeCompare(b.label) + }) + : SEARCH_PATH_OPTIONS + const selectedOption = getSearchPathOption(searchPath, schemaOptions) + const standardSchemaOptions = schemaOptions.filter( + (option) => !option.value.endsWith('_warehouse') + ) + const warehouseSchemaOptions = schemaOptions.filter((option) => + option.value.endsWith('_warehouse') + ) + const visibleStandardSchemaOptions = standardSchemaOptions.filter( + (option) => + isShowingMoreSchemas || + COMMON_SCHEMA_NAMES.includes(option.value) || + option.value === selectedOption.value + ) + const hiddenStandardSchemaCount = + standardSchemaOptions.length - visibleStandardSchemaOptions.length return ( @@ -73,49 +127,83 @@ export function SqlEditorSearchPathSelector() { type="button" size="tiny" variant="default" - className="h-[26px] justify-start gap-0 pr-3" + className={cn('h-[26px] justify-start gap-0 pr-3', className)} >
Search path - - {selectedOption.label} - + {selectedOption.label}
- + Search path - Unqualified table names resolve against this schema first. Custom represents a - multi-schema path. + Choose where your query looks first for unqualified table names.{' '} + + Learn more + - {SEARCH_PATH_OPTIONS.map((option) => ( - -
- 0 && ( + <> + + {warehouseSchemaOptions.length > 0 ? 'Postgres schemas' : 'Schemas'} + + {visibleStandardSchemaOptions.map((option) => ( + +
+ {option.label} + {option.description !== undefined && ( + + {option.description} + + )} +
+
+ ))} + {hiddenStandardSchemaCount > 0 && ( + { + event.preventDefault() + setIsShowingMoreSchemas(true) + }} > - {option.label} -
- - {option.description} - -
-
- ))} + Show {hiddenStandardSchemaCount} more schema + {hiddenStandardSchemaCount === 1 ? '' : 's'} + + )} + + )} + {warehouseSchemaOptions.length > 0 && ( + <> + + + Warehouse schemas + + {warehouseSchemaOptions.map((option) => ( + +
+ {option.label} + {option.description !== undefined && ( + + {option.description} + + )} +
+
+ ))} + + )}
diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx new file mode 100644 index 00000000000..b63d52322cd --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx @@ -0,0 +1,114 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { proxy, useSnapshot } from 'valtio' + +export type SqlWarehouseHealthPreset = 'healthy' | 'catching_up' | 'degraded' | 'error' +export type SqlEditorQueryControlMode = 'compact' | 'separate' + +export type SqlWarehouseResultSource = + | { type: 'search_path'; schema: string } + | { type: 'query_reference' } + +const SQL_WAREHOUSE_SCHEMA_PATTERN = /\b[a-zA-Z_][\w$]*_warehouse\s*\./i + +const SQL_WAREHOUSE_HEALTH_LABELS: Record = { + healthy: '', + catching_up: 'Catching up', + degraded: 'Degraded', + error: 'Error', +} + +const SQL_WAREHOUSE_HEALTH_TOOLTIPS: Record = { + healthy: 'Warehouse replication is caught up with Postgres.', + catching_up: + 'Warehouse replication is catching up. Recent writes may not appear in linked tables yet.', + degraded: 'Warehouse replication is degraded. Query results may be stale.', + error: 'Warehouse replication encountered an error. Check replication logs.', +} + +const SQL_WAREHOUSE_HEALTH_CLASS_NAMES: Record = { + healthy: 'text-foreground-light hover:text-foreground', + catching_up: 'text-warning hover:text-warning-600', + degraded: 'text-destructive hover:text-destructive-600', + error: 'text-destructive hover:text-destructive-600', +} + +export const sqlEditorWarehouseDemoStore = proxy<{ + health: SqlWarehouseHealthPreset + queryControlMode: SqlEditorQueryControlMode +}>({ + health: 'healthy', + queryControlMode: 'separate', +}) + +export function setSqlEditorWarehouseHealth(health: SqlWarehouseHealthPreset) { + sqlEditorWarehouseDemoStore.health = health +} + +export function setSqlEditorQueryControlMode(mode: SqlEditorQueryControlMode) { + sqlEditorWarehouseDemoStore.queryControlMode = mode +} + +export function resolveSqlWarehouseResultSource( + sql: string, + searchPath: string +): SqlWarehouseResultSource | undefined { + if (searchPath.endsWith('_warehouse')) { + return { type: 'search_path', schema: searchPath } + } + + if (SQL_WAREHOUSE_SCHEMA_PATTERN.test(sql)) { + return { type: 'query_reference' } + } + + return undefined +} + +function getWarehouseResultSourceTooltip(source: SqlWarehouseResultSource) { + if (source.type === 'search_path') { + return `Results are served from Warehouse because the search path starts with ${source.schema}.` + } + + return 'Results include a linked Warehouse table referenced directly in the SQL.' +} + +export function SqlWarehouseResultStatus({ source }: { source: SqlWarehouseResultSource }) { + const snap = useSnapshot(sqlEditorWarehouseDemoStore) + const health = snap.health as SqlWarehouseHealthPreset + const shouldShowHealth = health !== 'healthy' + + return ( + + + + + + Warehouse + + + + + {getWarehouseResultSourceTooltip(source)} + + + {shouldShowHealth && ( + <> + · + + + + + {SQL_WAREHOUSE_HEALTH_LABELS[health]} + + + + + {SQL_WAREHOUSE_HEALTH_TOOLTIPS[health]} + + + + )} + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx index 95a12f1bea2..448fec0c6ad 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx @@ -1,63 +1,47 @@ -import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' interface SqlResultsSummaryProps { rowCount: number autoLimit?: number } -type ResultsHealth = 'complete' | 'limited' - -const RESULTS_HEALTH_LABEL: Record = { - complete: 'Complete', - limited: 'Limited', -} - -const RESULTS_HEALTH_CLASS_NAME: Record = { - complete: 'text-foreground-light hover:text-foreground', - limited: 'text-warning hover:text-warning-600', -} - function formatRowCount(rowCount: number) { return `${rowCount.toLocaleString()} row${rowCount === 1 ? '' : 's'}` } export function SqlResultsSummary({ rowCount, autoLimit }: SqlResultsSummaryProps) { - const health: ResultsHealth = autoLimit !== undefined ? 'limited' : 'complete' + const hasReachedLimit = autoLimit !== undefined && rowCount >= autoLimit return (

- - - - - {RESULTS_HEALTH_LABEL[health]} - - - - - {health === 'limited' ? ( -

- - Results are limited to preserve browser performance when a query returns many rows. + {hasReachedLimit && ( + <> + + + + + Limited + - - Change or remove this limit from the dropdown near Run. - -
- ) : ( - 'Query completed without applying the SQL Editor row limit.' - )} - - - · + + +
+ + Results are limited to preserve browser performance when a query returns many + rows. + + + Change or remove this limit from the dropdown near Run. + +
+
+ + · + + )} {formatRowCount(rowCount)} - {autoLimit !== undefined && ( + {hasReachedLimit && ( (limit {autoLimit.toLocaleString()}) )} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index 141d27e6e72..722e5656909 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -2,6 +2,7 @@ import { useParams } from 'common' import { toast } from 'sonner' import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' +import { SqlWarehouseResultStatus } from '../SqlEditorWarehouseDemo' import { ChartConfig } from './ChartConfig' import { SqlResultsSummary } from './SqlResultsSummary' import { UtilityTabExplain } from './UtilityTabExplain' @@ -151,7 +152,12 @@ export const UtilityPanel = ({
{showResultsSummary && ( - + <> + {result.warehouseResultSource && ( + + )} + + )} {result?.rows && ( diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx index ae004c1a9c1..da76ea77bcc 100644 --- a/apps/studio/components/interfaces/UserDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown.tsx @@ -19,6 +19,7 @@ import { import { ButtonTooltip } from '../ui/ButtonTooltip' import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext' +import { SqlEditorWarehouseDemoMenu } from './UserDropdown/SqlEditorWarehouseDemoMenu' import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown' import { ProfileImage } from '@/components/ui/ProfileImage' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' @@ -136,6 +137,7 @@ export function UserDropdown({ Changelog + diff --git a/apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx b/apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx new file mode 100644 index 00000000000..ed1c7e53a4a --- /dev/null +++ b/apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx @@ -0,0 +1,89 @@ +import { Database } from 'lucide-react' +import { toast } from 'sonner' +import { + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from 'ui' +import { useSnapshot } from 'valtio' + +import { + setSqlEditorQueryControlMode, + setSqlEditorWarehouseHealth, + sqlEditorWarehouseDemoStore, + type SqlEditorQueryControlMode, + type SqlWarehouseHealthPreset, +} from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo' + +const WAREHOUSE_HEALTH_PRESETS: { preset: SqlWarehouseHealthPreset; label: string }[] = [ + { preset: 'healthy', label: 'Live' }, + { preset: 'catching_up', label: 'Catching up' }, + { preset: 'degraded', label: 'Degraded' }, + { preset: 'error', label: 'Error' }, +] + +const QUERY_CONTROL_MODES: { mode: SqlEditorQueryControlMode; label: string }[] = [ + { mode: 'compact', label: 'Compact Query dropdown' }, + { mode: 'separate', label: 'Separate controls' }, +] + +export function SqlEditorWarehouseDemoMenu() { + const snap = useSnapshot(sqlEditorWarehouseDemoStore) + + return ( + <> + + + + + SQL Warehouse demo + + + + Results health + + {WAREHOUSE_HEALTH_PRESETS.map(({ preset, label }) => ( + { + setSqlEditorWarehouseHealth(preset) + toast.message(`SQL Warehouse demo: ${label}`) + }} + > + {label} + + ))} + + + Query control + + { + if (value === 'compact' || value === 'separate') { + setSqlEditorQueryControlMode(value) + toast.message( + `SQL Warehouse demo: ${ + value === 'compact' ? 'Compact Query dropdown' : 'Separate controls' + }` + ) + } + }} + > + {QUERY_CONTROL_MODES.map(({ mode, label }) => ( + + {label} + + ))} + + + + + ) +} diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index 01788bdbf0c..a810e324268 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -39,6 +39,7 @@ interface DatabaseSelectorProps { className?: string align?: 'start' | 'end' isForm?: boolean + label?: string } export const DatabaseSelector = ({ @@ -50,6 +51,7 @@ export const DatabaseSelector = ({ align = 'end', className, isForm = false, + label = 'Source', }: DatabaseSelectorProps) => { const router = useRouter() const { ref: projectRef } = useParams() @@ -85,9 +87,7 @@ export const DatabaseSelector = ({ ) : ( <> - {isLoading || selectedDatabase?.identifier === projectRef - ? 'Primary database' - : 'Read replica'} + {isLoading || selectedDatabase?.identifier === projectRef ? 'Primary' : 'Read replica'} {isSuccess && selectedDatabase?.identifier !== projectRef && ( @@ -132,7 +132,7 @@ export const DatabaseSelector = ({ )} >
- Source + {label} {isLoading ? : selectedDatabaseLabel} @@ -222,7 +222,7 @@ export const DatabaseSelector = ({

{database.identifier === projectRef - ? 'Primary database' + ? 'Primary' : `Read replica (${region} - ${id})`}

{database.identifier === selectedDatabaseId && } diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts index 3718fac1f59..e8861d01686 100644 --- a/apps/studio/data/sql/execute-sql-mutation.ts +++ b/apps/studio/data/sql/execute-sql-mutation.ts @@ -11,6 +11,7 @@ import { calculateSummary, createNodeTree, } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser' +import type { SqlWarehouseResultSource } from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo' import { handleError as handleErrorFetchers, post } from '@/data/fetchers' import { MB } from '@/lib/constants' import { sqlEventParser } from '@/lib/sql-event-parser' @@ -65,6 +66,7 @@ type ExecuteSqlVariables = { type ExecuteSqlMutationVariables = ExecuteSqlVariables & { autoLimit?: number contextualInvalidation?: boolean + warehouseResultSource?: SqlWarehouseResultSource } /** diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.test.ts b/apps/studio/state/sql-editor/sql-editor-session-state.test.ts index 46768b2a979..7c5353d3d0d 100644 --- a/apps/studio/state/sql-editor/sql-editor-session-state.test.ts +++ b/apps/studio/state/sql-editor/sql-editor-session-state.test.ts @@ -22,6 +22,19 @@ describe('sqlEditorSessionState', () => { expect(result?.error).toBeUndefined() }) + it('addResult stores Warehouse result metadata', () => { + sqlEditorSessionState.addResult('a', [{ x: 1 }], undefined, { + type: 'search_path', + schema: 'public_warehouse', + }) + + const result = sqlEditorSessionState.results['a']?.[0] + expect(result?.warehouseResultSource).toEqual({ + type: 'search_path', + schema: 'public_warehouse', + }) + }) + it('addResultError stores the error with empty rows', () => { sqlEditorSessionState.addResultError('a', { message: 'boom' }, 25) diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.ts b/apps/studio/state/sql-editor/sql-editor-session-state.ts index 8cb9618c4fa..46173d49d08 100644 --- a/apps/studio/state/sql-editor/sql-editor-session-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-session-state.ts @@ -1,6 +1,14 @@ import { proxy, ref, snapshot, useSnapshot } from 'valtio' import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types' +import type { SqlWarehouseResultSource } from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo' + +export interface SqlEditorQueryResult { + rows: any[] + error?: any + autoLimit?: number + warehouseResultSource?: SqlWarehouseResultSource +} /** * Ephemeral, per-session SQL editor state that is NOT persisted: query results, @@ -15,11 +23,7 @@ export const sqlEditorSessionState = proxy({ * a single query with multiple statements can return multiple results. */ results: {} as { - [snippetId: string]: { - rows: any[] - error?: any - autoLimit?: number - }[] + [snippetId: string]: SqlEditorQueryResult[] }, /** EXPLAIN results, if any, keyed by snippet id. */ @@ -39,17 +43,27 @@ export const sqlEditorSessionState = proxy({ setLimit: (value: number) => (sqlEditorSessionState.limit = value), - addResult: (id: string, results: any[], autoLimit?: number) => { + addResult: ( + id: string, + results: any[], + autoLimit?: number, + warehouseResultSource?: SqlWarehouseResultSource + ) => { // Use ref() to prevent Valtio from creating proxies for each row object. // This is critical for large result sets - without ref(), Valtio wraps every // row and nested property in a Proxy, causing massive memory overhead. // Alright to use ref() in this case as the data is meant to be read-only and we // don't need to track changes to the underlying data - sqlEditorSessionState.results[id] = [{ rows: ref(results), autoLimit }] + sqlEditorSessionState.results[id] = [{ rows: ref(results), autoLimit, warehouseResultSource }] }, - addResultError: (id: string, error: any, autoLimit?: number) => { - sqlEditorSessionState.results[id] = [{ rows: ref([]), error, autoLimit }] + addResultError: ( + id: string, + error: any, + autoLimit?: number, + warehouseResultSource?: SqlWarehouseResultSource + ) => { + sqlEditorSessionState.results[id] = [{ rows: ref([]), error, autoLimit, warehouseResultSource }] }, resetResult: (id: string) => { From 949a57d2854b7fcadc0d621cb7fffa167506d581 Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 9 Jul 2026 14:18:07 -0500 Subject: [PATCH 4/5] content(www): update investor logo wall on company page (#47753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds 8 new investor logos: Accel, Craft, Figma, Georgian, GIC, Peak XV, Salesforce Ventures, Stripe - Reorders lead investor grid to match design mockup (3 rows of 4) - Adds per-logo `scale` field to control logo size within each cell - Adds `grayscaleOnly` field for Salesforce Ventures to preserve tonal contrast (prevents wordmark from being hidden by `contrast-0` filter) ## Test plan - [ ] Visit /company and verify all 12 investor logos render correctly across 3 rows - [ ] Check logo sizing and order matches the mockup - [ ] Verify Salesforce Ventures wordmark is visible inside the cloud shape - [ ] Check dark mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * Expanded and reordered the “Our investors” lead cards with additional entries (including Stripe, Salesforce Ventures, and others). * Enhanced logo presentation options for lead cards with per-investor sizing/positioning controls. * **Bug Fixes** * Improved lead investor card image rendering by removing internal scrolling and using an overflow-hidden container with scale-based sizing. * Preserved the existing logo filter behavior (opacity-only when configured, grayscale-only when selected, otherwise the default contrast treatment). --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com> --- apps/www/data/Investors.tsx | 103 ++++++++++++++++-- apps/www/pages/company.tsx | 18 +-- apps/www/public/images/logos/accel--grey.png | Bin 0 -> 3052 bytes apps/www/public/images/logos/craft--grey.png | Bin 0 -> 1807 bytes apps/www/public/images/logos/figma--grey.png | Bin 0 -> 2077 bytes .../public/images/logos/georgian--grey.png | Bin 0 -> 4148 bytes apps/www/public/images/logos/gic--grey.png | Bin 0 -> 3120 bytes apps/www/public/images/logos/peakxv--grey.png | Bin 0 -> 3309 bytes .../public/images/logos/salesforce--grey.png | Bin 0 -> 5702 bytes apps/www/public/images/logos/stripe--grey.png | Bin 0 -> 2758 bytes 10 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 apps/www/public/images/logos/accel--grey.png create mode 100644 apps/www/public/images/logos/craft--grey.png create mode 100644 apps/www/public/images/logos/figma--grey.png create mode 100644 apps/www/public/images/logos/georgian--grey.png create mode 100644 apps/www/public/images/logos/gic--grey.png create mode 100644 apps/www/public/images/logos/peakxv--grey.png create mode 100644 apps/www/public/images/logos/salesforce--grey.png create mode 100644 apps/www/public/images/logos/stripe--grey.png diff --git a/apps/www/data/Investors.tsx b/apps/www/data/Investors.tsx index a149f1efb4a..ed1adf033e6 100644 --- a/apps/www/data/Investors.tsx +++ b/apps/www/data/Investors.tsx @@ -4,26 +4,62 @@ type Investor = { title: string img: string lead: boolean + scale?: number + objectPosition?: string twitter?: string | null linkedIn?: string | null } const data: Investor[] = [ + // Row 1 + { + name: 'GIC', + round: 'B', + title: 'GIC', + img: '/images/logos/gic--grey.png', + lead: true, + scale: 1.5, + twitter: null, + linkedIn: null, + }, + { + name: 'Peak XV', + round: 'B', + title: 'Peak XV Partners', + img: '/images/logos/peakxv--grey.png', + lead: true, + scale: 0.85, + twitter: null, + linkedIn: null, + }, + { + name: 'Accel', + round: 'B', + title: 'Accel', + img: '/images/logos/accel--grey.png', + lead: true, + scale: 1.12, + twitter: null, + linkedIn: null, + }, + { + name: 'Craft', + round: 'B', + title: 'Craft Ventures', + img: '/images/logos/craft--grey.png', + lead: true, + scale: 0.61, + twitter: null, + linkedIn: null, + }, + // Row 2 { name: 'Coatue', round: 'SEED', title: 'Coatue Growth Fund', img: '/images/logos/coatue.png', lead: true, - twitter: null, - linkedIn: null, - }, - { - name: 'Mozilla', - round: 'SEED', - title: 'Mozilla', - img: '/images/logos/mozilla--grey.png', - lead: true, + scale: 0.95, twitter: null, linkedIn: null, }, @@ -36,6 +72,16 @@ const data: Investor[] = [ twitter: null, linkedIn: null, }, + { + name: 'Stripe', + round: 'B', + title: 'Stripe', + img: '/images/logos/stripe--grey.png', + lead: true, + scale: 1.15, + twitter: null, + linkedIn: null, + }, { name: 'Felicis', round: 'B', @@ -45,6 +91,45 @@ const data: Investor[] = [ twitter: null, linkedIn: null, }, + // Row 3 + { + name: 'Salesforce Ventures', + round: 'B', + title: 'Salesforce Ventures', + img: '/images/logos/salesforce--grey.png', + lead: true, + scale: 2.01, + twitter: null, + linkedIn: null, + }, + { + name: 'Georgian', + round: 'B', + title: 'Georgian', + img: '/images/logos/georgian--grey.png', + lead: true, + twitter: null, + linkedIn: null, + }, + { + name: 'Figma', + round: 'B', + title: 'Figma', + img: '/images/logos/figma--grey.png', + lead: true, + twitter: null, + linkedIn: null, + }, + { + name: 'Mozilla', + round: 'SEED', + title: 'Mozilla', + img: '/images/logos/mozilla--grey.png', + lead: true, + scale: 0.9, + twitter: null, + linkedIn: null, + }, { name: 'Tom Preston-Werner', round: 'A', diff --git a/apps/www/pages/company.tsx b/apps/www/pages/company.tsx index 544f0813e95..f73c19d9aa9 100644 --- a/apps/www/pages/company.tsx +++ b/apps/www/pages/company.tsx @@ -171,24 +171,18 @@ const Investors = () => {
{InvestorData.filter((x) => x.lead === true).map((x) => (
-
-
+
+
{x.name}
diff --git a/apps/www/public/images/logos/accel--grey.png b/apps/www/public/images/logos/accel--grey.png new file mode 100644 index 0000000000000000000000000000000000000000..f0590898664a332062070fd976e2668eb7494e43 GIT binary patch literal 3052 zcmYk8cQ_l|7sq3yR_$6*v0{%}HENV1i9Ku9iY<207&StPNN8!%Qd;x%8mU&JijoRy z)r+Dwp+ji}HQVw_-#*X#{Lb^7dq3xV&pG$r|8BOeHH@7V#0mfa*v;T3_5c7Kg2vIz z47Al#QX%knU~B1U3X8PFnFGJJ+W157+;r?W3D~)Lq7K^2E zR#w*kOSDmcR9efQ0$Qf`#)S#m=~6NF)((JjF0n4})GfnDOG}Z4qlCQF+bLTrwzpNh z#U9yu9(=6-@v@5;TQ9on5nlYo1(%|zb%yR{fF@?ZXHEaZ)12* zCI^@}p*aWRY9}92oF0|C-YqCXV}e%)r5kF9;5r3TJs%`FcYB;!!59v+XDm5ULyDop ziu~ng-n476zs^6uEwn?gaZE(ZI2z%#&Cb0!!~-LYa<$C@NgJ&{-J31qc5{*q*kQNuSQ zpHh)Pbp(j~!DIHY6l9Ze_@J4uC}qHvT4OYCu&ZuJJ;Z5)x0QT(r&DNySP(U?#lQ$w z@-d_g7{NonYBL!?Dgi{z%;AcGdEn-RfKb{+Cd8DPbVGbe3V~!h4ANthRze@8d%WAc z{9K*FOh#yDi<1&Zi$onULbnV3cjuC?km(a{-hErADo*j~(_{ndPqgZS9XfeL*6 z74hu$&m51oQNyi66lK+S_3s_6`5Scf8c#rQQv zkZ2$w+3JT$Yths0j;hCFxZLslI*nysWuR1HOH+ndcQt{eyF#YJZOEHT8k=)ek@m!6U z2wD$YXo7RQs}WU!cbE4nGA6%w!>UxxwXN3$r0Wj%RmO{PWC{MaCTF=i*vp>y4zf8I zyx3WH8XX&SK0kLTc%&kj4IK^@Dlwzl$E0|0lG*K&BkTK-7Zfj^CHBBWz91kJ_xFCN zz&LP(dpyZ(xmmb7YVQjJSoX4Ho!NvWx5BU~$NC)*Th}L{4wvABSGT~m{7EmplA-nm zwNMG=y<9tH&-GTzF<6y1-!v0=Nv=-V4yH1tjo-8^17*29R-U9o_ec3ubXVi1^}WM+ zEDxfe(&5fB&9w#2KG3_!5tU9!70|mAzldp2{_%aNlz|5)T5HjAXGp1)ok-TvXu;sG zTyxj7K{H064&Ld+?#SP`b^QSS&{^k3Rqi*^qM!}cR6IbNBZ$Nc=~ou)Z8YB^ftM`# z88N_Po7?DqSq@J2{yJvW*B07WqpE?@InUm*r-#3ZP8W0vf7h7y!1mhu2mb=GfP_9x?62DgYaAqD0>HQ;2%6?n zwi$M}$STc6Pn|d-yWg6*63gsqo>p9N%{5FB^+UIeSK?KjxgDM->kHB3qEQu%+aIrmxhP{yB*0;}KO#CdBrqHpRVfDuM8-0exT;)m|qp zXBH!2Hs;Hf^ABMpjFwwjMOPJCtlS*G15#zW;^2az#6cNS+8f=TX61g8`=w&`ZDn&5Gy~?sm z{d4foh6KfEG@RR@UXOdJNTe&B#eLcAQh5tF5l|kP?v9ha&cx5Q(b?l27Tlu#`#TOa0(CWLHg{4$ow^lU88X=k zSxW7<)7)|bg(G>F3hF2e%Xa+YX6|N@HQlAxLr#Ci?_?$^plYY}XNBKZyNWF#w|@DK z&NsORd`&plIB%VkJXTH7s#PMrkU2clSEJTQ@hp7eOHm%?k~`%-{%F98rbqm!i<2X0b)5uP5N*%(#YAk(!9VZa6MA*TZ`v?+g9d z8@6qIx5D1DKfr?fo|S!Sq}<~iHS{YDB3Rx;*az#ogY=O(qIJ(zg-3G!fu%7U1CZ-Q z9+&KGhIaRM)x@JZLqZi2USYt}c`IO)g9qXx58-hxQ6BfyGgl;YzMe$jny8KJM01xj zYpThpsLAell+?a?mi%k_vHFs4_-xbh&Y@1)h`7HjyTaUe>RQ8jbs-1f;t1>`BQ&P_ z=WSxs0HnRenoExgbL!Bj9BU6K9o&$Lo3N4HH6&73S-_%8SGgh|`KW-6&^VJclUmf933bGV$RDMI#6J)9k1B(1R0O;7#oY?( z>J)6l+m0!xH(8NBH4Z>^cBwMo+z HZ}k5FBqgDi literal 0 HcmV?d00001 diff --git a/apps/www/public/images/logos/craft--grey.png b/apps/www/public/images/logos/craft--grey.png new file mode 100644 index 0000000000000000000000000000000000000000..a8e2061077d4eb5301e778f33eeafeb1059ab7cb GIT binary patch literal 1807 zcmX|B2~<-@8%@HN$6=8dmLe8{KF|h)fQo`FNdQSgASf1MkpdQ}ER|K@4?!S?MFbMU zDvKskl?qW5lu{AWP)nf}&|g+15D+Yzq!myafj;1ze&@{n?%eNwGiT1snae)j9-8Vq z)e#7UCWY*F0)apQFsGrFVC;MJ(h*M4KD3kW9x=51t;o*KhI7GIT)A?E%jIrQVY1D) zF)uG~TZa)=U^|Skn46mmIj1HI#6|+ZU039UDB>PQZ={ z2s;*oV|?!Qa?=*^JiLJC6O$ek-OFtV76tGeU_a?BgA(TDg}}iajZi}FM5w?F2@hMO z(SO_lg+Xkd54lx=2^?g2Z2GqH`M3>#1VUw$;zm4~JhD(7`ArROM^?Z38Kt`EGcOV! zFI?-`*vNn7b*34ar(rO6R@$2(`x|}a03Vvc_`W<(W3GmrD4E{CjdWvoAFLaJibuG1 zQr~4%$1a7PT= z$l)Qz^iF}RB%5_AIDNguJ_rC#812*v?jLM5?moh7SPyA>h*cwMjeMZDy9y= zxF&lwDs?!QjMgBrz&1vXmSp@+0~U<1KMGQ{Ol3~9VAC$Jyxas3)UY?^VxMS%RC`wt zp>TZ{O=~#BYba}kgoZP`7@a^QU^)B#tJ~qsXFU-oaRm`WBLDuPjD4?)NMR8&p}k+2 zZ+JXjul60I_HpnV59|IcV_U&aPw-jh!ZXYsAV0jSV0A`T(9}>r+MQ~2!z!dOXaQd| zqQ|lk?fuYPI?>hOGQ{>+w2%9)_^ZOcNk!<3?$#d)v%Hq%-9nLpALPj7@$e`{LpyoQ z=ui1PzFNgoUhGZHmgndJsi{1_Z$6!?rJ2-?Ni>m7VW=dOb0cH+Mt+=j2Dc zMy)VxUqv@QvRv zMCM&H`j8AoRY-NUQo!-9zm6WEUV_ABk8T!WXGH%L9ZobvnipTC@YrGq;^S3;iw{nH z8E@!EmgrJ~$tb?u{~o|khZv7t3pr1Vf(@NKKz%yw1q}tN%(PeM$`{<>OZ|AA+H1_Y>$8^$ePubm5 zBBt3nTFvf%u81Wjzn8a_FD@NsRUS)m|HnUCyrHF~Fi2S*9&7eYxoeyG(E3Kx2eZqE z(k=zL>Q&W*RS!>JIqr0BDQj5m z7&Qk?I_i!2sz#d&7N~)yWbTh3u_NA%IqE zLikCz=EkSr-7;&j4s z^)FH8s_WsHwDik+F*@3Pp$doBo-F!x6Hr$@lO&v9R(sp+d~N_r@7nzRDem5GcS*tQ Fe*w@|XDKvh zFhp02ZKTae*0RMN2?^OF%l%fb*S){?b57BtQ&F|EfRp z&<b)?Pja}R~c^XLT@N0FIJa5+eB}N5Kc-hN8rof`OYqudKvf5!xP1- zJc>sBNB(z0Svzqsq=J8mR7D4J8#T2sYP6TdS8kf|TBF=m0WHkI3}*wN;=r8jU8N0a z=ll1R)IUE;?`r$@lx^)iweQ;68Y7{tmRDzb*($Yzp3NTsZH?>Ed#1I`GaNtBQJlzJ zJ;GQFm*2S58aDAeTmxyG_qG0#eU6vl`12i70yA8$!?%pj-TtGF7dLn&I3xOnYUv> z$%qKU;NBwd%-A#+X)zc}kl7Urk|I>Oy#axBGgsoQXH@#gfvvW8JPJ`j#p_EZ1^Du{ z7lFy$oicA3GPS3G0FyR!|ECX5hdV#zexUW%n7d?^3wfPURFS2O>FFGDB!@TDqQjC0 zG_c;Fo*R=aSLjKN|5fFk@u!&;2fD+taSHIs)iknxNz5h~XbF(zkTlsJE&UiilfB(C zi+#ig?7i(D-{))H+iXq~AuU$U&iq-slXwS9zc0rkGDkZPAu%mQk1(kg+KZwrj=m!+ z8Qm99Ahv8%=z1_;hT5*o>O81YS|9vnr;P#D>+U({W|H*cTP7(q<-xhvH*t|J!#Vuzx zT@r=+d$*q_X9bylYtj);@_s=w>>0nf(ygG=#6gvn-21axOHrLP8KHokX%p!e8Vrn; z#komk$n0qV$5C??c%8NZ`)}1ax#WvFLDwYbju8-KPTzgYdzQ+k9K^)xu@TK$5A$Sy zQ}P0evlDlM5UTA16r=n2`p>lXdq=pN=Qs@}P{Sz%pU~ClXK%v6yy+t>VjMMIXWDuC z_?j?#Lt#zBQGhHCrweGf96qYoGLVo^%ZzEtG1^XX(}YK7#?y*F?!^FW2sOn5#HIL9 zhE9Vd@VT~hk$NpZUe9d-pW$wP6|l0a>kK(=1PzwAHn0AlVBgq$&ZU^9w>^&4PNt6D})CdJ@WIFpd##)VQg7hE^t0UH$Za zg(%CT#^7l%n0+z+joG;&$&JVAY!P337sjw~T(MWU;^heB^$ZVm7O+4VN;(aWuSbiB zX?voH2D%hiODP~PaQ|=ifP?$6K5|i(b1TX30+(MVcR@V=LMmRE%_MZ|Cn9WkN94@J zwH4WlKI^LL#S4Ztf0PF$!Trkv!aZ_^AI?6zAyX%j$2bj&-XfK^G`*@h`f`H3V8}so zo~Vlo+JwxfJ0cRZC%hB^*(^0*!REBT#-~y`cp9COpc2)DtAV{6aAU@uS)vfQ*e(Q(O$Qi)T(el+t@)M8Lxc~E3qwqE z;T-rnEnfN9{J@zV05vo62ra(VAI5@PQZa-cyVduVcpP>)Db^s8ezqx%n=bE~JApUE z#y!v_8X>V)R9YkUeMOqsDl*rM)#LU zIX?8CG6w-4cOk2JupPPOi)Q^zs?W>Hm3ZdUHgx<+3HN~=(M?LQ@ASpHn?|;SqpE_c z^SS53JO8k#0_!6>oU&$!8|n)*J6FL(F)`F6Qq({4{proGLyFiSTIjm7u&jsk*c;i~|Qg#I;6t z>h9i^Cef`Q(2Y=juvOYnF_ikk@}*#7^#{k;>=( zS5I&Iq}tUF+OjChavSpw%?o2qj$bb6AmUExF9WqXET6@%O}05wSZ%*6W~h>+x<*zm)wIB`otY2ry!SL*^YMA$LO45!;&l^wC{ zZdmBbe`HbqtFz5+EuG3u+*;$?cRlHgMb=AOk(o-~wd)G_&0WY@qpDHa(!%fmptYr) JMTHp{^AGdU(4qhU literal 0 HcmV?d00001 diff --git a/apps/www/public/images/logos/georgian--grey.png b/apps/www/public/images/logos/georgian--grey.png new file mode 100644 index 0000000000000000000000000000000000000000..a305e5929394a3b445a6f9fbbc3b590294ccc218 GIT binary patch literal 4148 zcmX|^c{tSDAIHaC|OFzb}fl7W`yi+%4CT_wf^Sb`#it*^L##^*Eyf}InOzNo#&*3widzy2mu%jCTwMC<^Y3n zqS>6x$HmUiUy|7w76ICz%`HM~va_@Q_*Yr~iali4|8hn~2Ak8;(%6}8`F|@jGn3uI zpwHw!&amf(S>~g5^%bm)I&QQ8uJc z^JxN@sJTka$;k$@`+`GCyohyPo(bdV(~;~ICfA&;{4QUH0c_@jad9AEJZ$1%hc?IY z|FZ$-5!gTX2jYxtmy zg`51bV>Ih`pDZ12&H2#=yRS_DTaI6DMxBd*{WbB$?OsfbM&sA$sf$A6uKB8%f}e)L z<2{IBdU;7^dHaRBh=C)0`{aH7N&OwY$-8zRI$H@KS)^sq;K^h2)tA?$Mak;QCTdRJ z@RqL?-&>1k7enRq-!qJdpY(-s6I_xyEW=IdQLBy>5@nrN zqdi(f#tATuh8i4x1X))U9{c%b_pD_m|a!uVhDn`~OYXlF!|*;`9_^6Lsu^U+pgrBB9R zuevh=N7dZTt3OEmc4uWNTcYe)z8OO_I+le6Yqh!)Zs#W1#N`}MX)KdKAKLwvm5|q8 z(x@no_FRj&p^?5}@U)63(B_N9crs&Lr~;J!^U5pM(xij2Q4N_Eu_p_=;wq*4I*td8 z9}c@Ls0O`T2|&*`mhMfK?4~N*{-sm@*C1iB z#-&Z}kT{RiqtWxb+glu5g23#nn(}W(Bcbo*?6b9Bt9TMpZT5;H!MbLp@lVDp{xeeJ z&Nqu6iuPVP&I1hH-6{}wtKp(pSJC<|`KnlBK>Gp*X}&q#<2S``+09etMt19GJHLWf zZLWvVTbth}GrxZY$5k4d3+)hSrRV5?uNKIqvF>Njo?b&5nlc|fdvgha?mb(|Q**q% zKu#Oo%UVw5LFt5aLBxAmlE+(;h6$h-6?UiCnh{MobHoi z%#>c_4Y)c3IXumhqcrQJe8BEkfjRB0U<}URxl~nwqB5P(xQnv zr1{>~CA~E1gLWvaIv8%HB28t zz=EHvTGinw63`rX3SMrS^eL3sdG6}4BqT;=Jc6@MBcE9@lBZrj9keBIVmMML0#TES zFiMA?AxM&lxwe+4o?9LKfxA^1^^QEwz%8?$z=Q14M-9q#NE7)bT4|KxaweldedYNq zPGM0AKN%0GS&*s@G!FVLp^~kGN6uB``z3*e%8h3-C>!;-ZQkbH z?yUX}A;9A0lX%t*A*Hj%5t{~{hb4=i8d`x`YxyxvC;zww^**2-BfA^?h6iClJ15n_ z1{O5Ypcb-l^1G}+KMa$d|7u8tDGH!nJ?+N)jKR3H#n{@PYN524KPLA8%qi6NPN!33 zB3Gga!AP>nOzWDPhXDF;Q3~n@tJY4Ll_0AeAnAOM*cr1*i1X^l2(@juzCFHJYC7K@ zKPQsA&47=E&X8QlO-HLlips~2P0mp4ok~n8 z)(X-K`*}Q%Io_7v5zhNl6?4&h6pOk^va!R`O}mske>J+%K*9v&=$m9v%OUgfXYFlk z*Lyr04;N1aLJ{1?wPg;z4{=P3h=n?^;~$pR_Z zGM%fX#Pw0vlYf-(kP!YsBOhicKHL~yOfv%km*ly_`F3!gUnl;vx0oR-?C@=Q8gMFe9RsF zJ^xfOJ061Ulw-Nx+p5xwhfFYzSDt5n-OU?Lv4+9ra7d??p;F|0L}5|<<9=$u z@1eX6KlpH*ww-bA=f)sK2#Qi-7bGaDXZK?dLz|M`yZrrYkJZ9gs|>yO4E;-_t#);1 zK#YWF{huZz2jOEmBuaV?dqXE?!z+^?XQN5jwMm!gNDnH)06*pA!4G%QLjx?sq(Da1 zY5W9_3#Q8ilzLof?(m~;E)6ex1!>5MW(nu0ttI5=z?e9bp7KC)2!xjToYEhl4GFbq zNK$%BG<_~cuVNBQ+K>%LSvN6u$2}NwsFZ_|JsciJK=^9!bp%!Bfl?m%GOtEx^S;`pkem2H?ev75W$!uxt$-o6RWWC-8NE? z>os$FRZG=0+1mrSrRVCG@63^v#n z)?QjMv|Yxq1i#&Cg|xq$4xV3hr8a(U>fX(rxeH>*vHhKqmc{h#iS-Xo))w6ET)(o=`fdd9TwJcX zW8h~`()O1JjXH_i&V=@JRZ$v^730HTOJ!KWQoSiL%%-h1OdM0&Nqqwr8aeQ2%!!6a z8oyUg1AGk|Z<_Mn1s+6^V_obdD9az*wb9O$yZ1OT&eU%t`PHUXDM?CoWhtZb7^Po+ zi?6ZSKFPCr+&7nKa_+pk2;oN+R2%l|u^iC^JC3ZccJ69>cORAFI(6Db9RlBfub(F| z#&Ri-i6E&+k7HHV?xBdTi#(+5LNCN0!Zg9}t!y45c5bdUdgsfLlHZwf^yT`SDcas4 zCNp~J=Q8*)-Fj{nyN4mrVC{?P7$20_xp-F~U@EDM`)4SEm$~6@gVOaegS>9~-N5NX zpPaMQ6^waa0T!3iKg&s0hCsUt=PkL;l0c54Jx=JH5_&Z4-m})1X0@pglIVZj521g2 zlw{iAuZbSb)u+kfF!`L61;2K_(YsFLGHHu66Wdyminb~o*qwMH%T0Pc^-uZ>qr&3A zLsPF7Ghs}orcQW-p3c!5@~DVL^;7S^2-i&vgKZ(##>1&^Rz=rHC2xWT3E<< z&n^1WV1UZ{#~$0Oz=}$Zh-AK>yS3~!zXE}yI}X5Ld>+1TTc)8DbtBT_#XvIuBc0o! zQRVyBZm5VAF-3*X_;y5xiv~a7P4CCDei=&s)Ek|V74Iy@+l-i3RL67g6&v}kT4Y?O zo47%eSBEdEuu4P)LoK@Qu_j0y$Hot95JV*`3l{0K7R_h_bK1XQQ7rF9d6a$RO+IBq zwm6`Y=JC7YCCp{r#1RMg)~lhXyVNVV1^mjI~iT6`HZUBWq#EK1J4sA%-GZlJzB(N7e}mW6AWQjY5TF zh_UZX*_za3&n{b*{2uk5_nhB9zw^7!eP8$Ixv%eYU-xs)^T#&^Z*IuDPi!9y2IDn4 zqkj$tW1~a-g@*&mXGFK(L5m;W)Cy6do*ObDPnFmeAf)Lr)4Iden@sjBLa^mAI!bmb=Ap-h$k060NFFepci!AY>h}}X0T%r^T0UR#9(_M zVuPj1I=CCuhrXc#|8%5w_&7z+A3&hHhao?M2#=%%4>zT zvabV3aR&>Aa`=vogg~ywjl-T~&b6Q47~Hz~+E1+PpP{n!2g9fzs5eS2u{Df+QY{u^ zp`fxRJIDvuCGvpK_0*MU#v#DQ-cBl*7A(IMdD#jC`H*7h$F2nv7LRQ#+e!XECWi_rl^_Mfz5UWlXS z*KP>;U-X#bV-(Dik$bQDI094igSfZrKp^cF?b+Nk#>?hqruAV(dWRlx%im=^^m@Od z9NjQ5H1uaGJUMzWT1+es>6Qd^g3cei!t*38BoWEyA6Y z*&+2ox=wj(it**ggeY-Rn5w8R3g+-y>$TscUL{YSzDAtYGY?p$h680Ar$&ow9Q@jA zYT?NZ@vm|!Kd!A_k6S&aYq$iY71;GE40G7L$(N(Tdu4C^D2k#z?M~9v%Qx(Ea63~t zmzS7V+ekOKv|wd7xLN9wa+IE}RjbThVpGN%7a^N-3CiMfWa-w6335fL7E32}i`H1< z>rZQax_zUv9=_U>*MY~By&p1>tHMg{n?;16D+I}L@{BCf#K2x0!MA(cOdQOv68t7E zKeS=fkeG)Mcw+byvzgoCgL>tLvUpf-uxH=b7m%#O{6 zhZdF+zI)G3To-us-nq+#@9LE~CDebw40n@%7#>3~M9cYUqtN7WsjGV5D)n+{N>9mX;m|e~}b0tde z`vfPj-JFUPK4G794H0WutXM^L2d7I|;$6~Avlk8}_Z{b-OBf{}$_zqzwFA1e=L|!H ziq_HgO0GE^mOfy|>k$LLhyD9Z`v9B%Z(z&ih_Ty^>CSQ9kA*W5_bSWx@V=H2G)hN) zugF_geO00ndmRV%lefhjX{<4)Ps|I>YBK_)0kN!zm?VMZ)r|hBnaf2l9gW8;Nrgv$ zn7v(G{kcJFmQ`^EHH+dKeL6Dl*wlSGEJmIH2D2Hf%n#DmS1jf~- z-MEF}JDI(jb0^I3se4oBzC0h$DZjnArgxpjGSkwSmrjl%;4)8B&4_9$E@xg~Uglyu zHVXtPRDR>L_Ua;gm4!N^>ez*=DUWbCK^+xN8u-Vwes=mLJG)QdLKeFY5*(xr^%c?Y;;J^SS=9V#~@hCv&e4uURtuJ2${6Wobdk1M3 z?-F5^$pt_e2zO)y!?pgcKO@+GvtjPV_A~V(!d>O(H+k2Na?Ri7Y-P> z@f=Ty$r`i&(EHe}{%(6;&u6={@EnnT-twn7#bBSGW#TsSsYcU0Vy1s9$sfBsz z#&i{NZ(L|v*+1R%DYQ3jp}5-rA(j$WfS`LPQZmNh6;n8_>AR?qGBA1mTZMAIVBKQF zYXa85@z1KZj^};+DQ-dYH3Y$zp$Z@n#h{`lR#Mzh`wQ9wv0haeA#R57b|cVG`(BoD z2$cM|oPb=U){X>{P+@te%1M;D4FX~PqHRYGeJ5wM6TdW&7T^=2LfF8BV@pKJ)u#=) zH-DU2BB5F?dwoUUU2$G7)6Ritj6GOBfg`Jjoc*sJ` zSp{czur=_QYW~aMEJ6BBW|VhtIHH=S@1hgrDY?=WOLeeeiskSx*LD{TnuYBC)4^7+ z@|xpk7wQ&*y*)q9q|ZsWB~l4(B$3Xw`fjBlxNVj+eWFI~18Z-H>$<5gFgWh#>M?$A z#dRu(48%QDx+67Rl+mT+_V^P5QC`Q&m0*oIvZUaW z@vvlXeP9prnbwI%1^eI;e%Bfs;mq})>k@DN2Q|RHssI20 literal 0 HcmV?d00001 diff --git a/apps/www/public/images/logos/peakxv--grey.png b/apps/www/public/images/logos/peakxv--grey.png new file mode 100644 index 0000000000000000000000000000000000000000..fdb8969dd1619b3808c7294d78c082593261aef7 GIT binary patch literal 3309 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 Qzt2*rx;~^<)h6u!07>7oI{*Lx literal 0 HcmV?d00001 diff --git a/apps/www/public/images/logos/stripe--grey.png b/apps/www/public/images/logos/stripe--grey.png new file mode 100644 index 0000000000000000000000000000000000000000..d0fc63c4cc4cbb35cb4d16eb4e9b680e5cadf989 GIT binary patch literal 2758 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 literal 0 HcmV?d00001 From 9004d04aca9c11156a1b2b4de8bbb5aa1e598920 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:04:07 -0400 Subject: [PATCH 5/5] better saving --- .../SQLEditor/SqlEditorQueryBar.tsx | 22 +++++-------- .../SQLEditor/UtilityPanel/AutosaveStatus.tsx | 23 ++----------- .../SQLEditor/UtilityPanel/SaveButton.tsx | 33 ++++++++++++------- .../state/shortcuts/registry/sql-editor.ts | 7 ++++ 4 files changed, 39 insertions(+), 46 deletions(-) diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx index e6ad1c9cbdc..89ad1a74b20 100644 --- a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx @@ -143,9 +143,8 @@ export function SqlEditorQueryBar({ className="flex h-10 shrink-0 items-center justify-between border-b bg-dash-sidebar px-2 dark:bg-surface-100 @container" >
- {IS_PLATFORM && } - {/* SavingIndicator reports auto-save progress (spinner/checkmark). In manual - mode AutosaveStatus + the Save button own the status, so hide it there. */} + {IS_PLATFORM && } + {/* SavingIndicator reports auto-save progress. In manual mode the Save button owns status. */} {IS_PLATFORM && !isManualSaveEnabled && }
@@ -218,16 +217,13 @@ export function SqlEditorQueryBar({ /> )} -
- {isManualSaveEnabled && } - -
+ {isManualSaveEnabled && } +
) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx index abff6c14b7a..b6603e19cfe 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx @@ -8,13 +8,8 @@ import { } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { IS_PLATFORM } from '@/lib/constants' import { useTrack } from '@/lib/telemetry/track' -import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle' -import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' -export type AutosaveStatusProps = { id: string } - -export const AutosaveStatus = ({ id }: AutosaveStatusProps) => { - const snapV2 = useSqlEditorV2StateSnapshot() +export const AutosaveStatus = () => { const track = useTrack() const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled() const { selectFeaturePreview } = useFeaturePreviewModal() @@ -27,21 +22,7 @@ export const AutosaveStatus = ({ id }: AutosaveStatusProps) => { const sqlEditorManualSaveFlag = useFlag('sqlEditorManualSave') const canEnableManualSave = IS_PLATFORM && sqlEditorManualSaveFlag - if (isManualSaveEnabled) { - const snippet = snapV2.snippets[id] - // A snippet only enters the store on its first edit, so a snippet that - // isn't in the store yet is a fresh, blank, untouched "new query" tab — - // there's nothing to report a save status for. - if (snippet === undefined) return null - - const unsavedChanges = hasUnsavedChanges(snippet.snippet.status) - - return ( - - {unsavedChanges ? 'Unsaved edits' : 'Saved'} - - ) - } + if (isManualSaveEnabled) return null return (
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx index 87ce9d5e08b..4be5930ff1e 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx @@ -1,16 +1,17 @@ import { Loader2 } from 'lucide-react' -import { Button, KeyboardShortcut } from 'ui' +import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { hasUnsavedChanges, isSaving } from '@/state/sql-editor/sql-editor-lifecycle' import { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' interface SqlSaveButtonProps { id: string - className?: string } -export const SqlSaveButton = ({ id, className }: SqlSaveButtonProps) => { +export const SqlSaveButton = ({ id }: SqlSaveButtonProps) => { const snapV2 = useSqlEditorV2StateSnapshot() const { requestSave } = useSqlEditorSaveCoordinator() @@ -18,23 +19,31 @@ export const SqlSaveButton = ({ id, className }: SqlSaveButtonProps) => { const saving = isSaving(status) const isDirty = hasUnsavedChanges(status) && !saving - return ( + const button = ( ) + + if (isDirty) { + return {button} + } + + return ( + + + {button} + + + {saving ? 'Saving changes...' : 'All changes saved'} + + + ) } diff --git a/apps/studio/state/shortcuts/registry/sql-editor.ts b/apps/studio/state/shortcuts/registry/sql-editor.ts index e6409505516..24bb0863f74 100644 --- a/apps/studio/state/shortcuts/registry/sql-editor.ts +++ b/apps/studio/state/shortcuts/registry/sql-editor.ts @@ -7,6 +7,7 @@ export const SQL_EDITOR_SHORTCUT_IDS = { SQL_EDITOR_EXPLAIN: 'sql-editor.explain', SQL_EDITOR_NEW_SNIPPET: 'sql-editor.new-snippet', SQL_EDITOR_RUN: 'sql-editor.run', + SQL_EDITOR_SAVE: 'sql-editor.save', } export type SqlEditorShortcutId = @@ -49,4 +50,10 @@ export const sqlEditorRegistry: RegistryDefinations = { sequence: ['Mod+Enter'], showInSettings: false, }, + [SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_SAVE]: { + id: SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_SAVE, + label: 'Save edits', + sequence: ['Mod+S'], + showInSettings: false, + }, }