From 501666e50497302e6d8c0e80fc7a1266d445e186 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 18 Sep 2026 14:35:35 +0800 Subject: [PATCH 01/31] Explorer home chat to present a Run SQL secondary action if value is detected to be a SQL query (#50560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context We previously introduced a behaviour for the explorer home tab's chat form to run a SQL Query if the input is detected to be a SQL query. Adjusting it to shift that behaviour into a secondary action instead image ## Summary by CodeRabbit * **New Features** * SQL statements entered in Explorer can be run directly with a dedicated “Run SQL” action. * Assistant forms support context-specific submit icons, labels, tooltips, and accessibility text. * **Bug Fixes** * Improved SQL detection for multi-statement queries. * Prevented mixed SQL and conversational text from being treated as executable SQL. --- .../interfaces/Explorer/ExplorerHomeTab.tsx | 13 +++-- .../Explorer/ExplorerHomeTab.utils.test.ts | 2 + .../Explorer/ExplorerHomeTab.utils.ts | 27 +++++++--- .../ui/AIAssistantPanel/AssistantChatForm.tsx | 51 ++++++++++++++++--- 4 files changed, 76 insertions(+), 17 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.tsx index 4accbab5a83..cb391fbda93 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.tsx @@ -55,6 +55,7 @@ const ExplorerHomeContent = () => { const { createChat } = useCreateChat() const [value, setValue] = useState('') + const isSqlQuery = isSqlStatement(value) return (
@@ -75,10 +76,14 @@ const ExplorerHomeContent = () => { placeholder="Explore your data, check project health, create a notebook..." value={value} onValueChange={(e) => setValue(e.target.value)} - onSubmit={(message) => - isSqlStatement(message) - ? createQuery({ sql: message, autoRun: true }) - : createChat({ initialMessage: message }) + onSubmit={(message) => createChat({ initialMessage: message })} + secondaryAction={ + isSqlQuery + ? { + label: 'Run SQL', + onClick: () => createQuery({ sql: value, autoRun: true }), + } + : undefined } /> diff --git a/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.utils.test.ts b/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.utils.test.ts index 805bdc73e97..192839ca529 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.utils.test.ts +++ b/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.utils.test.ts @@ -19,6 +19,7 @@ describe('isSqlStatement', () => { 'SHOW ALL;', 'set search_path to public', "SET TIME ZONE 'UTC'", + 'select * from a;\n\nselect * from b;', ])('returns true for %s', (message) => { expect(isSqlStatement(message)).toBe(true) }) @@ -34,6 +35,7 @@ describe('isSqlStatement', () => { 'Show me my tables', 'Set up RLS on my users table', 'With my current schema, what tables should I add?', + 'select * from colors;\n\nhelp me figure out what is wrong with this', ])('returns false for %s', (message) => { expect(isSqlStatement(message)).toBe(false) }) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.utils.ts b/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.utils.ts index 37358997ce4..f96c7ac2316 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.utils.ts +++ b/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.utils.ts @@ -17,17 +17,32 @@ const SET_STATEMENT_REGEX = /^\s*set\s+(session\s+|local\s+)?(time\s+zone\b|[a-zA-Z_][a-zA-Z0-9_.]*\s*(=|to)\s*\S)/i const WITH_STATEMENT_REGEX = /^\s*with\s+(recursive\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s+as\s*\(/i +const isSqlLikeSegment = (segment: string): boolean => + UNAMBIGUOUS_SQL_STATEMENT_REGEX.test(segment) || + CREATE_STATEMENT_REGEX.test(segment) || + SHOW_STATEMENT_REGEX.test(segment) || + SET_STATEMENT_REGEX.test(segment) || + WITH_STATEMENT_REGEX.test(segment) + /** * Whether `message` looks like a SQL statement rather than a natural-language chat prompt, * so the Explorer home tab can route it to a query tab instead of creating an AI chat. */ export function isSqlStatement(message: string): boolean { - const sql = removeCommentsFromSql(message) - return ( + const sql = removeCommentsFromSql(message).trim() + + const isSimpleStatement = UNAMBIGUOUS_SQL_STATEMENT_REGEX.test(sql) || - CREATE_STATEMENT_REGEX.test(sql) || SHOW_STATEMENT_REGEX.test(sql) || - SET_STATEMENT_REGEX.test(sql) || - WITH_STATEMENT_REGEX.test(sql) - ) + SET_STATEMENT_REGEX.test(sql) + const isComplexStatement = CREATE_STATEMENT_REGEX.test(sql) || WITH_STATEMENT_REGEX.test(sql) + + if (isComplexStatement) return true + if (!isSimpleStatement) return false + + // Simple statements (select/insert/update/.../show/set) are rarely legitimately + // multi-paragraph, so a trailing paragraph that doesn't itself look like SQL means + // this is a chat message that happens to include a query, not a query to run. + const [, ...trailingParagraphs] = sql.split(/\n\s*\n/) + return trailingParagraphs.every(isSqlLikeSegment) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx index e5a6c8a4c9f..0c3a2d1600b 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx @@ -1,7 +1,8 @@ import { useBreakpoint } from 'common' +import { AnimatePresence, motion } from 'framer-motion' import { ArrowUp, Loader2, Square } from 'lucide-react' -import { ChangeEvent, FormEvent, forwardRef, KeyboardEvent, memo, useRef } from 'react' -import { cn, ExpandingTextArea } from 'ui' +import { ChangeEvent, FormEvent, forwardRef, KeyboardEvent, memo, ReactNode, useRef } from 'react' +import { Button, cn, ExpandingTextArea } from 'ui' import { ButtonTooltip } from '../ButtonTooltip' import { formatAttachedSnippets } from './AIAssistant.utils' @@ -49,6 +50,18 @@ export interface FormProps { selectedModel?: AssistantModelId /* Callback when a model is chosen */ onSelectModel?: (model: AssistantModelId) => void + /* Icon for the submit button when not loading, defaults to an up arrow */ + submitIcon?: ReactNode + /* Label for the submit button when not loading, used for both aria-label and tooltip text, defaults to "Send message" */ + submitLabel?: string + /** + * An additional action shown beside the main submit button, transitioned in/out with + * framer-motion. Pass undefined to hide it (e.g. when the input doesn't match its condition). + */ + secondaryAction?: { + label: string + onClick: () => void + } } const AssistantChatFormComponent = forwardRef( @@ -70,6 +83,9 @@ const AssistantChatFormComponent = forwardRef( isEditing = false, selectedModel, onSelectModel, + submitIcon = , + submitLabel = 'Send message', + secondaryAction, ...props }, _ref @@ -134,7 +150,7 @@ const AssistantChatFormComponent = forwardRef( />
@@ -144,7 +160,28 @@ const AssistantChatFormComponent = forwardRef(
)} -
+
+ + {!loading && secondaryAction && ( + + + + )} + {loading ? ( onStop ? ( ( } + aria-label={submitLabel} + icon={submitIcon} disabled={!canSubmit} className={cn( 'w-7 h-7 rounded-full p-0 text-center flex items-center justify-center', !canSubmit ? 'opacity-50' : 'opacity-100' )} - tooltip={{ content: { side: 'top', text: 'Send message' } }} + tooltip={{ content: { side: 'top', text: submitLabel } }} /> )}
From 252f69e4517e682a8fd9c69b5d3b1101216ee5b9 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 18 Sep 2026 16:38:29 +1000 Subject: [PATCH 02/31] chore(studio): refine Explorer sidebar, onboarding, and notebooks (#50555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A round of small Explorer refinements. **Sidebar** - Adds a **Run SQL** row (with a `+` icon) above Notebooks in the Explorer sidebar; opens a new query tab. **Assistant** - Assistant query cells now have the same **Save** dropdown as query tabs (add to an existing notebook or create a new one). It shows only when Explorer is enabled, and not while the query is still streaming. - `SaveQueryDropdown` takes an optional `source`, so logs queries are saved as log cells (keeping their time range) instead of database cells. This also fixes saving logs queries from query tabs. - The "Drafting notebook..." notice (and the notebook loading/status rows) now span the full message width; `delete_notebook` parts use the wide layout like create/update. **Onboarding** - Replaces the single page with a four-step walkthrough: Welcome to Explorer (with a **Preview** badge), Run SQL, Notebooks, and Chat with your project. Each step has an icon, heading, and short description, with step dots and **Skip** / **Back** / **Next** buttons; the last step ends with **Continue to Explorer**. - Removes the "Choose how Explorer opens" choice (still available in Account preferences) and the collapsible "Learn more" section. Skipping or finishing still respects the saved startup preference. - Deletes `ExplorerOnboardingLearnMore`, `ExplorerHomePreference`, and `ExplorerHomePreview`, which were only used by onboarding. **Notebooks** - Query cells use the same max width as markdown cells (`48rem`, was `72rem`). - "Add query cell" / "Add markdown cell" are now **Add query** / **Add markdown** everywhere; the buttons at the bottom of a notebook are larger (34px, 18px icons). ## Test plan - [ ] Explorer sidebar: **Run SQL** opens a new query tab - [ ] Assistant: generate SQL, use **Save** to add it to a new and an existing notebook; repeat with a logs query and confirm a log cell is created - [ ] Assistant: ask for a notebook and confirm the drafting notice is full width - [ ] Clear `hasCompletedOnboarding` in Explorer preferences and step through onboarding (Next / Back / Skip); finishing or skipping respects the startup preference set in Account preferences - [ ] Notebook: query cells line up with markdown cell width; bottom add buttons are larger ## Summary by CodeRabbit - **New Features** - Added a **Run SQL** shortcut to Explorer navigation. - Assistant query results can now be saved to notebooks, including log queries. - **Improvements** - Updated Explorer onboarding with guided steps, progress navigation, and visual previews. - Shortened Explorer action labels and refined control sizing. - Reduced notebook query layout width and adjusted assistant notebook displays. - **Changes** - Removed the Explorer startup preference selector and onboarding “Learn more” section. --------- Co-authored-by: Claude Opus 5 --- .../Preferences/ExplorerHomePreference.tsx | 56 --- .../Preferences/ExplorerHomePreview.tsx | 47 --- .../interfaces/Explorer/AddCellDropdown.tsx | 4 +- .../Explorer/ExplorerNotebookTab.tsx | 14 +- .../Explorer/ExplorerOnboarding.tsx | 126 ++++++- .../Explorer/ExplorerOnboardingLearnMore.tsx | 40 -- .../Explorer/ExplorerOnboardingWireframes.tsx | 347 ++++++++++++++++++ .../interfaces/Explorer/ExplorerQueryTab.tsx | 5 +- .../interfaces/Explorer/QueryCell/index.tsx | 2 +- .../interfaces/Explorer/SaveQueryDropdown.tsx | 15 +- .../__tests__/ExplorerHomeTab.test.tsx | 50 ++- .../ExplorerLayout/ExplorerNavHome.tsx | 15 +- .../AIAssistantPanel/AssistantQueryCell.tsx | 8 + .../ui/AIAssistantPanel/Message.Parts.tsx | 8 +- .../NotebookProposalRenderer.tsx | 6 +- 15 files changed, 530 insertions(+), 213 deletions(-) delete mode 100644 apps/studio/components/interfaces/Account/Preferences/ExplorerHomePreference.tsx delete mode 100644 apps/studio/components/interfaces/Account/Preferences/ExplorerHomePreview.tsx delete mode 100644 apps/studio/components/interfaces/Explorer/ExplorerOnboardingLearnMore.tsx create mode 100644 apps/studio/components/interfaces/Explorer/ExplorerOnboardingWireframes.tsx diff --git a/apps/studio/components/interfaces/Account/Preferences/ExplorerHomePreference.tsx b/apps/studio/components/interfaces/Account/Preferences/ExplorerHomePreference.tsx deleted file mode 100644 index 4b3c46bc3cc..00000000000 --- a/apps/studio/components/interfaces/Account/Preferences/ExplorerHomePreference.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { RadioGroupCard, RadioGroupCardItem } from 'ui' - -import { ExplorerHomePreview } from './ExplorerHomePreview' -import { explorerHomeSchema, type ExplorerHome } from './useExplorerPreferences' - -interface ExplorerHomePreferenceProps { - value: ExplorerHome - onValueChange: (value: ExplorerHome) => void - disabled?: boolean -} - -export const ExplorerHomePreference = ({ - value, - onValueChange, - disabled, -}: ExplorerHomePreferenceProps) => ( - { - const result = explorerHomeSchema.safeParse(value) - if (result.success) onValueChange(result.data) - }} - disabled={disabled} - className="grid-cols-1 gap-3 sm:grid-cols-2" - > - - Start page - - Choose a query, chat, or notebook to get started. - - - } - > - - - - SQL query - - Open a new query tab, ready to write SQL. - - - } - > - - - -) diff --git a/apps/studio/components/interfaces/Account/Preferences/ExplorerHomePreview.tsx b/apps/studio/components/interfaces/Account/Preferences/ExplorerHomePreview.tsx deleted file mode 100644 index c0407221b93..00000000000 --- a/apps/studio/components/interfaces/Account/Preferences/ExplorerHomePreview.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { ExplorerHome } from './useExplorerPreferences' - -export const ExplorerHomePreview = ({ home }: { home: ExplorerHome }) => ( - -) diff --git a/apps/studio/components/interfaces/Explorer/AddCellDropdown.tsx b/apps/studio/components/interfaces/Explorer/AddCellDropdown.tsx index f63f2b83a5d..90cdff05034 100644 --- a/apps/studio/components/interfaces/Explorer/AddCellDropdown.tsx +++ b/apps/studio/components/interfaces/Explorer/AddCellDropdown.tsx @@ -35,11 +35,11 @@ export const AddCellDropdown = ({ cellId }: AddCellDropdownProps) => { onSelectAddCell('query')}> - Add query cell + Add query onSelectAddCell('markdown')}> - Add markdown cell + Add markdown diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index c1b24419ba2..7e8b92fb198 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -477,8 +477,8 @@ export const ExplorerNotebookTab = () => { contentClassName="[&>h3]:text-sm [&>p]:text-xs" >
- - + +
)} @@ -513,17 +513,19 @@ export const ExplorerNotebookTab = () => {
} - className="w-7" + className="w-[34px]" onClick={() => onSelectAddCell('query')} - tooltip={{ content: { side: 'bottom', text: 'Add query cell' } }} + tooltip={{ content: { side: 'bottom', text: 'Add query' } }} /> } - className="w-7" + className="w-[34px]" onClick={() => onSelectAddCell('markdown')} - tooltip={{ content: { side: 'bottom', text: 'Add markdown cell' } }} + tooltip={{ content: { side: 'bottom', text: 'Add markdown' } }} />
diff --git a/apps/studio/components/interfaces/Explorer/ExplorerOnboarding.tsx b/apps/studio/components/interfaces/Explorer/ExplorerOnboarding.tsx index ed370257255..ec3ac3c42e1 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerOnboarding.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerOnboarding.tsx @@ -1,33 +1,121 @@ -import { Button } from 'ui' +import { ArrowRight } from 'lucide-react' +import { useState, type ComponentType } from 'react' +import { Badge, Button, cn } from 'ui' -import { ExplorerOnboardingLearnMore } from './ExplorerOnboardingLearnMore' -import { ExplorerHomePreference } from '@/components/interfaces/Account/Preferences/ExplorerHomePreference' +import { + ChatWireframe, + NotebooksWireframe, + RunSqlWireframe, + WelcomeWireframe, +} from './ExplorerOnboardingWireframes' import { useExplorerPreferences } from '@/components/interfaces/Account/Preferences/useExplorerPreferences' +const ONBOARDING_STEPS: Array<{ + Wireframe: ComponentType + title: string + description: string + isPreview?: boolean +}> = [ + { + Wireframe: WelcomeWireframe, + title: 'Welcome to Explorer', + description: + 'Interact with your database and logs in one place. Run SQL, save your work to notebooks, and chat with Assistant.', + isPreview: true, + }, + { + Wireframe: RunSqlWireframe, + title: 'Run SQL', + description: + 'Run any SQL on your database, just like in the SQL Editor. You can also query your logs and chart the results.', + }, + { + Wireframe: NotebooksWireframe, + title: 'Notebooks', + description: + 'Snippets have evolved into notebooks. Combine multiple queries with markdown notes for you, your team, or Assistant to run later.', + }, + { + Wireframe: ChatWireframe, + title: 'Chat with your project', + description: + 'Ask Assistant to write queries, explain results, or build notebooks for you. Your organization’s AI settings control what it can access.', + }, +] + export const ExplorerOnboarding = () => { - const { home, setHome, completeOnboarding, isReady } = useExplorerPreferences() + const { completeOnboarding, isReady } = useExplorerPreferences() + const [stepIndex, setStepIndex] = useState(0) + + const step = ONBOARDING_STEPS[stepIndex] + const isFirstStep = stepIndex === 0 + const isLastStep = stepIndex === ONBOARDING_STEPS.length - 1 + const { Wireframe } = step return (
-
-

Welcome to Explorer

-

- Interact with your database and logs in one place. Run SQL, chat with Assistant, or - combine queries and notes in notebooks. -

+
+ +
+
+

{step.title}

+ {step.isPreview && Preview} +
+

{step.description}

+
-
-

Choose how Explorer opens

- +
+
+ + Step {stepIndex + 1} of {ONBOARDING_STEPS.length} + + {ONBOARDING_STEPS.map(({ title }, index) => ( +
+ +
+ {!isLastStep && ( + + )} + {!isFirstStep && ( + + )} + {isLastStep ? ( + + ) : ( + + )} +
- - - -
) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerOnboardingLearnMore.tsx b/apps/studio/components/interfaces/Explorer/ExplorerOnboardingLearnMore.tsx deleted file mode 100644 index dd1b616b616..00000000000 --- a/apps/studio/components/interfaces/Explorer/ExplorerOnboardingLearnMore.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Card, CardContent } from 'ui' -import { CollapsibleCardSection } from 'ui-patterns/CollapsibleCardSection' - -export const ExplorerOnboardingLearnMore = () => ( - - - -
-
-
Where did my snippets go?
-
- Notebooks are intended to replace snippets over time. Your saved snippets are still - accessible by switching back to the SQL Editor. -
-
-
-
Do notebooks replace custom reports?
-
- Notebooks are intended to replace custom reports over time. Your custom reports are - still accessible under Observability. -
-
-
-
Who can see my notebooks?
-
- Notebooks are shared with everyone on your team by default. -
-
-
-
Can AI see my data?
-
- Your organization’s AI settings control whether Assistant can access schema, logs, and - query results. -
-
-
-
-
-
-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerOnboardingWireframes.tsx b/apps/studio/components/interfaces/Explorer/ExplorerOnboardingWireframes.tsx new file mode 100644 index 00000000000..08bfa0b4339 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerOnboardingWireframes.tsx @@ -0,0 +1,347 @@ +import { + ArrowUp, + ChevronLeft, + ChevronRight, + Home, + MessageSquare, + NotebookText, + Play, + Plus, + SquareCode, + type LucideIcon, +} from 'lucide-react' +import type { ReactNode } from 'react' +import { cn } from 'ui' + +type Section = 'home' | 'query' | 'notebook' | 'chat' + +const Line = ({ className }: { className?: string }) => ( +
+) + +const Icon = ({ icon: IconComponent, className }: { icon: LucideIcon; className?: string }) => ( + +) + +const SidebarRow = ({ + icon, + isActive = false, + trailing, + lineClassName = 'w-10', +}: { + icon: LucideIcon + isActive?: boolean + trailing?: ReactNode + lineClassName?: string +}) => ( +
+ + +
{trailing}
+
+) + +const Count = () =>
+ +const SidebarHome = ({ section }: { section: Section }) => ( + <> +
+ } + lineClassName="w-9" + /> + + + + + } + lineClassName="w-12" + /> + + + + + } + lineClassName="w-8" + /> +
+
+ + + + + +
+ +) + +const SidebarNotebooks = () => ( + <> +
+ +
+
+ + + + + +
+ +) + +const Sidebar = ({ section }: { section: Section }) => ( +
+
+ {section === 'notebook' && } + +
+ +
+
+
+ {section === 'notebook' ? : } +
+
+) + +const TAB_ICONS: Record, LucideIcon> = { + query: SquareCode, + notebook: NotebookText, + chat: MessageSquare, +} + +const Tab = ({ icon, isActive = false }: { icon: LucideIcon; isActive?: boolean }) => ( +
+ + +
+) + +const TabBar = ({ section }: { section: Section }) => ( +
+
+ +
+ {section !== 'home' && } + +
+ +
+
+) + +const ExplorerFrame = ({ section, children }: { section: Section; children: ReactNode }) => ( + +) + +const SqlLines = ({ rows = 3 }: { rows?: number }) => { + const widths = [ + ['w-6', 'w-14'], + ['w-5', 'w-10'], + ['w-6', 'w-16'], + ['w-4', 'w-8'], + ] + + return ( +
+ {widths.slice(0, rows).map(([keyword, rest], index) => ( +
+ + {index + 1} + + + +
+ ))} +
+ ) +} + +const ResultsGrid = ({ rows = 3, className }: { rows?: number; className?: string }) => ( +
+ {Array.from({ length: rows + 1 }).map((_, row) => ( +
+ {Array.from({ length: 4 }).map((_, col) => ( + + ))} +
+ ))} +
+) + +const BarChart = ({ className }: { className?: string }) => ( +
+ {[35, 55, 45, 70, 60, 85, 75, 95].map((height, index) => ( +
+ ))} +
+) + +const RunButton = () => ( +
+ +
+
+) + +const HomeContent = () => ( +
+ +
+ +
+
+ +
+
+
+
+ {[SquareCode, NotebookText].map((icon, index) => ( +
+
+ +
+
+ + +
+
+ ))} +
+
+ +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+
+
+) + +const QueryContent = () => ( +
+
+ + +
+
+ +
+ +
+) + +const MarkdownCell = ({ lines }: { lines: string[] }) => ( +
+ + {lines.map((width, index) => ( + + ))} +
+) + +const NotebookContent = () => ( +
+
+ + +
+
+ +
+
+ + +
+
+) + +const ChatContent = () => ( +
+
+
+ + +
+
+
+ + +
+ + +
+
+
+ + Ask a follow up question... + +
+ +
+
+
+) + +export const WelcomeWireframe = () => ( + + + +) + +export const RunSqlWireframe = () => ( + + + +) + +export const NotebooksWireframe = () => ( + + + +) + +export const ChatWireframe = () => ( + + + +) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx index fd187c0c57e..f855c9d7d7c 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx @@ -166,7 +166,10 @@ export const ExplorerQueryTab = () => { }} toolbarActions={ <> - + } /> diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index 6588f486c3d..a152e3a9262 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -110,7 +110,7 @@ export const QueryCell = forwardRef(function return ( } gripDropdownContent={} gripClassName="mt-2 sm:opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition" diff --git a/apps/studio/components/interfaces/Explorer/SaveQueryDropdown.tsx b/apps/studio/components/interfaces/Explorer/SaveQueryDropdown.tsx index 3c04204dc93..22395f2a943 100644 --- a/apps/studio/components/interfaces/Explorer/SaveQueryDropdown.tsx +++ b/apps/studio/components/interfaces/Explorer/SaveQueryDropdown.tsx @@ -22,18 +22,22 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ExplorerToolbarAction } from './ExplorerToolbar' import { useCreateNotebook } from './hooks' -import { createQueryCellSkeleton } from './utils' +import { createLogCellSkeleton, createQueryCellSkeleton } from './utils' import { getNotebook } from '@/data/content/notebooks/notebook-query' import { useNotebooksInfiniteQuery } from '@/data/content/notebooks/notebooks-infinite-query' +import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry' import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' interface SaveQueryDropdownProps { query: { title: string; sql: string } + /** Saves as a log cell when the query targets logs. Defaults to a database cell. */ + source?: QuerySourceBinding } export const SaveQueryDropdown = ({ children, query, + source, }: PropsWithChildren) => { const router = useRouter() const { ref } = useParams() @@ -53,9 +57,14 @@ export const SaveQueryDropdown = ({ return items }, [notebooksData?.pages]) + const createCell = () => + source?._tag === 'logs' + ? createLogCellSkeleton({ ...query, time_range: source.time_range }) + : createQueryCellSkeleton(query) + const onAddToNewNotebook = () => { createNotebook({ - cells: [createQueryCellSkeleton(query)], + cells: [createCell()], }) } @@ -69,7 +78,7 @@ export const SaveQueryDropdown = ({ notebooksSnap.insertCellAfter({ id: notebookId, - cell: createQueryCellSkeleton(query), + cell: createCell(), }) notebooksSnap.requestScrollToBottom(notebookId) diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerHomeTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerHomeTab.test.tsx index 5bf942fbdb1..77ae31debef 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerHomeTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerHomeTab.test.tsx @@ -54,12 +54,23 @@ beforeEach(() => { afterEach(() => localStorage.clear()) describe('Explorer home onboarding', () => { - it('lets the user complete onboarding with the start page and does not show it on return', async () => { + it('steps through onboarding, completes it with the start page, and does not show it on return', async () => { const user = userEvent.setup() const first = renderHome() expect(await screen.findByRole('heading', { name: 'Welcome to Explorer' })).toBeVisible() - expect(screen.getByRole('radio', { name: /^Start page/ })).toBeChecked() - await user.click(screen.getByRole('button', { name: 'Open Explorer' })) + expect(screen.getByText('Step 1 of 4')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() + + for (const name of ['Run SQL', 'Notebooks', 'Chat with your project']) { + await user.click(screen.getByRole('button', { name: 'Next' })) + expect(screen.getByRole('heading', { name })).toBeVisible() + } + expect(screen.getByText('Step 4 of 4')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Skip' })).not.toBeInTheDocument() + expect(screen.queryByRole('radio')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Continue to Explorer' })) expect(await screen.findByLabelText('Start page chat')).toBeInTheDocument() first.unmount() @@ -69,26 +80,23 @@ describe('Explorer home onboarding', () => { expect(createQuery).not.toHaveBeenCalled() }) - it('supports keyboard selection and waits for completion before opening a query', async () => { + it('goes back to the previous step', async () => { const user = userEvent.setup() renderHome() - const startPage = await screen.findByRole('radio', { name: /^Start page/ }) - await user.click(startPage) - // Radix defers moving focus; keep the key down until that focus event selects the radio. - await user.keyboard('{ArrowRight>}') - await waitFor(() => expect(screen.getByRole('radio', { name: /^SQL query/ })).toBeChecked()) - await user.keyboard('{/ArrowRight}') - expect(createQuery).not.toHaveBeenCalled() - await user.click(screen.getByRole('button', { name: 'Open Explorer' })) - await waitFor(() => expect(createQuery).toHaveBeenCalledExactlyOnceWith({ replace: true })) + await screen.findByRole('heading', { name: 'Welcome to Explorer' }) + await user.click(screen.getByRole('button', { name: 'Next' })) + await user.click(screen.getByRole('button', { name: 'Back' })) + expect(screen.getByRole('heading', { name: 'Welcome to Explorer' })).toBeVisible() }) - it('shows onboarding for an unfinished query preference', async () => { + it('opens a query when onboarding is skipped and the saved preference is a query', async () => { + const user = userEvent.setup() seedPreferences('query', false) renderHome() expect(await screen.findByRole('heading', { name: 'Welcome to Explorer' })).toBeVisible() - expect(screen.getByRole('radio', { name: /^SQL query/ })).toBeChecked() expect(createQuery).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'Skip' })) + await waitFor(() => expect(createQuery).toHaveBeenCalledExactlyOnceWith({ replace: true })) }) it('opens one query for a returning user, even under Strict Mode', async () => { @@ -115,16 +123,4 @@ describe('Explorer home onboarding', () => { await waitFor(() => expect(createQuery).toHaveBeenCalledExactlyOnceWith({ replace: true })) } ) - - it('keeps Learn more collapsed initially and supports expanding and collapsing with the keyboard', async () => { - const user = userEvent.setup() - renderHome() - const trigger = await screen.findByRole('button', { name: 'Learn more' }) - expect(trigger).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('Where did my snippets go?')).not.toBeInTheDocument() - await user.click(trigger) - expect(screen.getByText('Where did my snippets go?')).toBeVisible() - await user.keyboard('{Enter}') - expect(trigger).toHaveAttribute('aria-expanded', 'false') - }) }) diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx index 4b969b9f64b..ba8b4789867 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { motion } from 'framer-motion' -import { ChevronRight } from 'lucide-react' +import { ChevronRight, Plus, SquareCode } from 'lucide-react' import Link from 'next/link' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -12,7 +12,7 @@ import { rowClassName, } from './ExplorerLayout.constants' import { formatRelativeTimeShort, getRecentlyUpdatedItems } from './ExplorerNavHome.utils' -import { useCreateChat } from '@/components/interfaces/Explorer/hooks' +import { useCreateChat, useCreateQuery } from '@/components/interfaces/Explorer/hooks' import { useContentCountQuery } from '@/data/content/content-count-query' import { useNotebooksInfiniteQuery } from '@/data/content/notebooks/notebooks-infinite-query' import { useAiAssistantChatList } from '@/state/ai-assistant-state' @@ -24,6 +24,7 @@ export const ExplorerNavHome = ({ }) => { const { ref } = useParams() const { openChat } = useCreateChat() + const { createQuery } = useCreateQuery() const { data: notebooksData } = useNotebooksInfiniteQuery({ projectRef: ref, limit: 100 }) const notebooks = notebooksData?.pages.flatMap((page) => page.content) ?? [] @@ -50,6 +51,16 @@ export const ExplorerNavHome = ({ className="absolute inset-0 flex flex-col gap-4 overflow-y-auto p-3" >
From d9cfdcd741dc90b4c3bf4794d90f250c85346324 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 18 Sep 2026 09:45:32 +0200 Subject: [PATCH 05/31] feat(studio): deep-link folders and files in the storage explorer (#50413) | | PR | Base | Branch | | --- | --- | --- | --- | | 1 | #50476 | `master` | pre-existing correctness fixes | | 2 | **this PR** | `fix/storage-explorer-listing-and-scroll` | `?path`/`?preview` deep-linking | | 3 | #50478 | `feat/storage-nav-improvement` | end-to-end deep-link test | | 4 | #50480 | `test/storage-deep-link-e2e` | copy path / copy link row actions | ## What is the current behavior? The file explorer doesn't keep track of folder navigation. Files and folders paths aren't shareable ## What is the new behavior? With this PR: - nav state is stored via params - "path" to store folder path (if nested folder paths) - "preview" to store the selected filename - back/forward nav history - file url opens correct folder/file [https://github.com/user-attachments/assets/](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[0cfb7fcc-2c6e](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[528d5c1d-a1b9](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[-](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[4f5a-950d](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[4061-9b67](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[-](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[060c8eb2027b](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[a41dd98716e0](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0) ## Steps to review - Open bucket in Storage File Explorer - navigate between files and folders and notice url params change - reload page, it should reopen where you left off - hitting back/forward on the browser history should follow file/folder navigation history --------- Co-authored-by: Claude Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com> --- .../FileExplorerHeader.test.tsx | 25 +- .../StorageExplorer/FileExplorerHeader.tsx | 32 +- .../StorageExplorer/FileExplorerRow.tsx | 20 +- .../Storage/StorageExplorer/PreviewPane.tsx | 13 +- .../StorageExplorer/StorageExplorer.tsx | 194 +------- .../StorageExplorer.utils.test.ts | 42 ++ .../StorageExplorer/StorageExplorer.utils.tsx | 13 + .../StorageExplorerContent.tsx | 134 ++++++ .../StorageExplorerNavigation.test.tsx | 447 ++++++++++++++++++ .../StorageExplorerNavigation.tsx | 267 +++++++++++ .../useStorageExplorerShortcuts.ts | 14 +- .../useStorageExplorerUrlState.ts | 44 ++ e2e/studio/utils/storage-helpers.ts | 4 +- 13 files changed, 1016 insertions(+), 233 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerContent.tsx create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.test.tsx create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.tsx create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerUrlState.ts diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx index 594e087836f..f4e0fac0aa5 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.test.tsx @@ -11,11 +11,15 @@ const { mockUseStorageExplorerStateSnapshot, mockUseAsyncCheckPermissions, mockUseStoragePreference, + mockGoUpOneLevel, + mockNavigateToPath, } = vi.hoisted(() => ({ mockTrack: vi.fn(), mockUseStorageExplorerStateSnapshot: vi.fn(), mockUseAsyncCheckPermissions: vi.fn(), mockUseStoragePreference: vi.fn(), + mockGoUpOneLevel: vi.fn(), + mockNavigateToPath: vi.fn(), })) vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => mockTrack })) @@ -26,6 +30,13 @@ vi.mock('@/hooks/misc/useCheckPermissions', () => ({ useAsyncCheckPermissions: () => mockUseAsyncCheckPermissions(), })) +vi.mock('./StorageExplorerNavigation', () => ({ + useStorageExplorerNavigation: () => ({ + goUpOneLevel: mockGoUpOneLevel, + navigateToPath: mockNavigateToPath, + }), +})) + vi.mock('./useStoragePreference', () => ({ useStoragePreference: (...args: any[]) => mockUseStoragePreference(...args), })) @@ -77,6 +88,8 @@ describe('FileExplorerHeader', () => { mockUseStorageExplorerStateSnapshot.mockReset() mockUseAsyncCheckPermissions.mockReset() mockUseStoragePreference.mockReset() + mockGoUpOneLevel.mockReset() + mockNavigateToPath.mockReset() mockUseStorageExplorerStateSnapshot.mockReturnValue(createSnapshot()) mockUseStoragePreference.mockReturnValue(createPreference()) @@ -166,9 +179,7 @@ describe('FileExplorerHeader', () => { await userEvent.click(screen.getByRole('button', { name: 'Go up one level' })) - expect(snapshot.popColumn).toHaveBeenCalled() - expect(snapshot.popOpenedFolders).toHaveBeenCalled() - expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(undefined) + expect(mockGoUpOneLevel).toHaveBeenCalled() }) it('opens path edit mode from Navigate and tracks the click', async () => { @@ -189,7 +200,7 @@ describe('FileExplorerHeader', () => { expect(screen.getByRole('button', { name: 'Navigate' })).toBeInTheDocument() }) - it('submits a path, tracks the submission, and calls the existing path navigation flow', async () => { + it('submits a path, tracks the submission, and navigates to it', async () => { const snapshot = createSnapshot() mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) @@ -209,7 +220,7 @@ describe('FileExplorerHeader', () => { await userEvent.click(screen.getByRole('button', { name: 'Navigate' })) await waitFor(() => { - expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({ paths: ['archive', '2025'] }) + expect(mockNavigateToPath).toHaveBeenCalledWith(['archive', '2025']) }) await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_submitted') @@ -237,11 +248,9 @@ describe('FileExplorerHeader', () => { await userEvent.click(screen.getByRole('button', { name: 'Navigate' })) await waitFor(() => { - expect(snapshot.popColumnAtIndex).toHaveBeenCalledWith(0) + expect(mockNavigateToPath).toHaveBeenCalledWith([]) }) - expect(snapshot.clearOpenedFolders).toHaveBeenCalled() - expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith(undefined) expect(mockTrack).toHaveBeenCalledWith('storage_explorer_navigate_clicked') expect(mockTrack).not.toHaveBeenCalledWith('storage_explorer_navigate_submitted') }) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx index a24aa30095c..41da3332a3a 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx @@ -44,6 +44,7 @@ import { Input } from 'ui-patterns/DataInputs/Input' import { STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER, STORAGE_VIEWS } from '../Storage.constants' import { pageChromeRowClassName } from './storageExplorerChrome' +import { useStorageExplorerNavigation } from './StorageExplorerNavigation' import { useFileExplorerHeaderShortcuts } from './useFileExplorerHeaderShortcuts' import { useStoragePreference } from './useStoragePreference' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -143,7 +144,6 @@ export const FileExplorerHeader = ({ const track = useTrack() const [pathString, setPathString] = useState('') - const [loading, setLoading] = useState({ isLoading: false, message: '' }) const [isPathDialogOpen, setIsPathDialogOpen] = useState(false) @@ -154,17 +154,13 @@ export const FileExplorerHeader = ({ const { projectRef, columns, - popColumn, - popColumnAtIndex, - popOpenedFolders, - fetchFoldersByPath, refetchAllOpenedFolders, refreshAll, isRefreshing, addNewFolderPlaceholder, - clearOpenedFolders, setSelectedFilePreview, } = useStorageExplorerStateSnapshot() + const { goUpOneLevel, navigateToPath } = useStorageExplorerNavigation() const { view, setView, @@ -216,9 +212,7 @@ export const FileExplorerHeader = ({ }, [breadcrumbs]) const onSelectBack = () => { - popColumn() - popOpenedFolders() - setSelectedFilePreview(undefined) + goUpOneLevel() } const onSelectUpload = () => { @@ -238,7 +232,7 @@ export const FileExplorerHeader = ({ setPathString(event.target.value) } - const navigateByPathString = async (event?: SyntheticEvent) => { + const navigateByPathString = (event?: SyntheticEvent) => { if (event) { event.preventDefault() event.stopPropagation() @@ -246,26 +240,14 @@ export const FileExplorerHeader = ({ const paths = compact(pathString.split('/')) setIsPathDialogOpen(false) - await onSetPathByString(paths) + // Writing the URL is enough — the navigation provider rebuilds the columns from it. + navigateToPath(paths) if (paths.length > 0) { track('storage_explorer_navigate_submitted') } } - const onSetPathByString = async (paths: string[]) => { - if (paths.length === 0) { - popColumnAtIndex(0) - clearOpenedFolders() - setSelectedFilePreview(undefined) - } else { - const pathString = paths.join('/') - setLoading({ isLoading: true, message: `Navigating to ${pathString}...` }) - await fetchFoldersByPath({ paths }) - setLoading({ isLoading: false, message: '' }) - } - } - const cancelSetPathString = () => { setIsPathDialogOpen(false) } @@ -347,7 +329,7 @@ export const FileExplorerHeader = ({ variant="outline" aria-label="Navigate" className="w-7 px-1" - disabled={isPathDialogOpen || loading.isLoading} + disabled={isPathDialogOpen} onClick={onOpenNavigate} /> )} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index 85e53daec36..dbd9a5711ca 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -39,6 +39,7 @@ import { StorageRowIcon } from '../StorageRowIcon' import { useFileExplorerContextMenu } from './FileExplorerRowContextMenu' import { FileExplorerRowEditing } from './FileExplorerRowEditing' import { copyPathToFolder } from './StorageExplorer.utils' +import { useStorageExplorerNavigation } from './StorageExplorerNavigation' import { useCopyUrl } from './useCopyUrl' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { formatBytes } from '@/lib/helpers' @@ -65,20 +66,16 @@ export const FileExplorerRow = ({ selectedBucket, selectedFilePreview, openedFolders, - popColumnAtIndex, - popOpenedFoldersAtIndex, - clearSelectedItems, - setSelectedFilePreview, setSelectedFileCustomExpiry, setSelectedItems, setSelectedItemsToDelete, downloadFile, setSelectedItemToRename, setSelectedItemsToMove, - openFolder, downloadFolder, selectRangeItems, } = useStorageExplorerStateSnapshot() + const { openFolderAtIndex, setPreviewedFile, clearPreviewedFile } = useStorageExplorerNavigation() const { onCopyUrl } = useCopyUrl() const ctx = useFileExplorerContextMenu() @@ -90,13 +87,6 @@ export const FileExplorerRow = ({ const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id) const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') - const onSelectFile = async (columnIndex: number) => { - popColumnAtIndex(columnIndex) - popOpenedFoldersAtIndex(columnIndex - 1) - setSelectedFilePreview(itemWithColumnIndex) - clearSelectedItems() - } - const onCheckItem = (isShiftKeyHeld: boolean) => { // Select a range if shift is held down if (isShiftKeyHeld && selectedItems.length !== 0) { @@ -110,7 +100,7 @@ export const FileExplorerRow = ({ } else { setSelectedItems([...selectedItems, itemWithColumnIndex]) } - setSelectedFilePreview(undefined) + clearPreviewedFile() } const rowOptions = @@ -263,8 +253,8 @@ export const FileExplorerRow = ({ event.preventDefault() if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) { item.type === STORAGE_ROW_TYPES.FOLDER - ? openFolder(columnIndex, item) - : onSelectFile(columnIndex) + ? openFolderAtIndex(columnIndex, item) + : setPreviewedFile(itemWithColumnIndex) } }} > diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx index acab8371de5..d551847736b 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx @@ -12,6 +12,7 @@ import { import { URL_EXPIRY_DURATION } from '../Storage.constants' import { StorageItem } from '../Storage.types' import { getPathAlongOpenedFolders } from './StorageExplorer.utils' +import { useStorageExplorerNavigation } from './StorageExplorerNavigation' import { useCopyUrl } from './useCopyUrl' import { useFetchFileUrlQuery } from './useFetchFileUrlQuery' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -119,10 +120,10 @@ export const PreviewPane = () => { selectedBucket, selectedFilePreview: file, setSelectedItemsToDelete, - setSelectedFilePreview, setSelectedFileCustomExpiry, downloadFile, } = useStorageExplorerStateSnapshot() + const { clearPreviewedFile } = useStorageExplorerNavigation() const { onCopyUrl } = useCopyUrl() const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') @@ -141,8 +142,14 @@ export const PreviewPane = () => { style={{ width }} > {/* Preview Header */} -
- setSelectedFilePreview(undefined)} /> +
+
{/* Preview Thumbnail*/} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx index 6bc07b56742..1ab2da94f98 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx @@ -1,198 +1,46 @@ import { useDebounce } from '@uidotdev/usehooks' import { useParams } from 'common' -import { compact, get, isEmpty, uniqBy } from 'lodash' -import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react' +import { useState } from 'react' import { useSelectedBucket } from '../FilesBuckets/useSelectedBucket' -import { STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants' -import { ConfirmDeleteModal } from './ConfirmDeleteModal' -import { CustomExpiryModal } from './CustomExpiryModal' -import { FileExplorer } from './FileExplorer' -import { FileExplorerHeader } from './FileExplorerHeader' -import { FileExplorerHeaderSelection } from './FileExplorerHeaderSelection' -import { MoveItemsModal } from './MoveItemsModal' -import { PreviewPane } from './PreviewPane' -import { useStorageExplorerShortcuts } from './useStorageExplorerShortcuts' -import { useStoragePreference } from './useStoragePreference' +import { StorageExplorerContent } from './StorageExplorerContent' +import { StorageExplorerNavigationProvider } from './StorageExplorerNavigation' import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' -import type { Bucket } from '@/data/storage/buckets-query' import { IS_PLATFORM } from '@/lib/constants' import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer' export const StorageExplorer = () => { const { ref, bucketId } = useParams() - const storageExplorerRef = useRef(null) - const { - projectRef, - columns, - selectedItems, - openedFolders, - selectedItemsToMove, - selectedBucket, - fetchFolderContents, - fetchMoreFolderContents, - fetchFoldersByPath, - uploadFiles, - moveFiles, - popColumnAtIndex, - popOpenedFoldersAtIndex, - setSelectedItems, - clearSelectedItems, - setSelectedFilePreview, - setSelectedItemsToMove, - setIsSearching, - } = useStorageExplorerStateSnapshot() - const { view } = useStoragePreference(projectRef) + const { selectedBucket } = useStorageExplorerStateSnapshot() useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM }) const { data: bucket, isLoading: isBucketQueryLoading } = useSelectedBucket() - // Detect when transitioning between buckets to avoid showing stale content from the previous bucket. - // This happens because the bucket query and effects that update the store run after the first render. - const isLoading = isBucketQueryLoading || (!!bucketId && bucketId !== selectedBucket.id) + // Guards the gap where the query has settled but the store still holds another bucket. + const isBucketReady = !isBucketQueryLoading && !!bucket && bucketId === selectedBucket.id - // This state exists outside of the header because FileExplorerColumn needs to listen to these as well - // Things like showing results from a search filter is "temporary", hence we use react state to manage + // Deliberately not in the URL, so a shared link points at a folder, not someone's filter. const [itemSearchString, setItemSearchString] = useState('') const debouncedSearchString = useDebounce(itemSearchString, 500) - const handleClearSearch = useCallback(() => { - setIsSearching(false) - setItemSearchString('') - }, [setIsSearching]) - - useStorageExplorerShortcuts({ onClearSearch: handleClearSearch }) - - const fetchContents = useEffectEvent(async (bucket: Bucket) => { - if (view === STORAGE_VIEWS.LIST) { - const currentFolderIdx = openedFolders.length - 1 - const currentFolder = openedFolders[currentFolderIdx] - - const folderId = !currentFolder ? bucket.id : currentFolder.id - const folderName = !currentFolder ? bucket.name : currentFolder.name - const index = !currentFolder ? -1 : currentFolderIdx - - await fetchFolderContents({ - bucketId: bucket.id, - folderId, - folderName, - index, - searchString: debouncedSearchString, - }) - } else if (view === STORAGE_VIEWS.COLUMNS) { - if (openedFolders.length > 0) { - const paths = openedFolders.map((folder) => folder.name) - fetchFoldersByPath({ - paths, - searchString: debouncedSearchString, - showLoading: true, - }) - } else { - await fetchFolderContents({ - bucketId: bucket.id, - folderId: bucket.id, - folderName: bucket.name, - index: -1, - searchString: debouncedSearchString, - }) - } - } - }) - - useEffect(() => { - if (bucket && projectRef) fetchContents(bucket) - }, [bucket, projectRef, debouncedSearchString, selectedBucket.id]) - - /** Checkbox selection methods */ - /** [Joshen] We'll only support checkbox selection for files ONLY */ - - const onSelectAllItemsInColumn = (columnIndex: number) => { - const columnFiles = columns[columnIndex].items - .filter((item) => item.type === STORAGE_ROW_TYPES.FILE) - .map((item) => { - return { ...item, columnIndex } - }) - const columnFilesId = compact(columnFiles.map((item) => item.id)) - const selectedItemsFromColumn = selectedItems.filter( - (item) => item.id && columnFilesId.includes(item.id) - ) - - if (selectedItemsFromColumn.length === columnFiles.length) { - // Deselect all items from column - const updatedSelectedItems = selectedItems.filter( - (item) => item.id && !columnFilesId.includes(item.id) - ) - setSelectedItems(updatedSelectedItems) - } else { - // Select all items from column - const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id') - setSelectedItems(updatedSelectedItems) - } - } - - /** File manipulation methods */ - - const onFilesUpload = async (event: any, columnIndex: number = -1) => { - event.persist() - const items = event.target.files || event.dataTransfer.items - const isDrop = !isEmpty(get(event, ['dataTransfer', 'items'], [])) - await uploadFiles({ files: items, columnIndex, isDrop }) - event.target.value = '' - } - - const onMoveSelectedFiles = async (newPath: string) => { - await moveFiles(newPath) - } - - /** Misc UI methods */ - const onSelectColumnEmptySpace = (columnIndex: number) => { - popColumnAtIndex(columnIndex) - popOpenedFoldersAtIndex(columnIndex - 1) - setSelectedFilePreview(undefined) - clearSelectedItems() - } - return ( -
- {selectedItems.length === 0 ? ( - + {/* The skeleton swap is silent, and a live region must be mounted before it changes. + `aria-live` rather than `role="status"`: that role is how toasts announce + themselves here, and tests wait on it to tell when an upload has finished. */} + + {isBucketReady ? 'Bucket contents loaded' : 'Loading bucket contents'} + + + - ) : ( - - )} -
- - fetchMoreFolderContents({ index, column, searchString: itemSearchString }) - } - /> - -
- - - - 0} - projectRef={projectRef} - bucketId={selectedBucket.id} - bucketName={selectedBucket.name} - selectedItemsToMove={selectedItemsToMove} - openedFolders={openedFolders} - onSelectCancel={() => setSelectedItemsToMove([])} - onSelectMove={onMoveSelectedFiles} - /> - - +
) } diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts index b0baa3aaa1f..6d4569da354 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts @@ -9,7 +9,9 @@ import type { StorageItem } from '@/components/interfaces/Storage/Storage.types' import { getPathAlongFoldersToIndex, getPathAlongOpenedFolders, + parseStoragePath, sanitizeNameForDuplicateInColumn, + serializeStoragePath, validateFolderName, } from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils' @@ -276,3 +278,43 @@ describe('sanitizeNameForDuplicateInColumn', () => { }) }) }) + +describe('parseStoragePath', () => { + it('returns an empty array for an absent or empty param', () => { + expect(parseStoragePath(null)).toEqual([]) + expect(parseStoragePath(undefined)).toEqual([]) + expect(parseStoragePath('')).toEqual([]) + }) + + it('splits a slash-joined path into segments', () => { + expect(parseStoragePath('a/b/c')).toEqual(['a', 'b', 'c']) + }) + + it('tolerates leading, trailing and repeated slashes', () => { + expect(parseStoragePath('/a//b/')).toEqual(['a', 'b']) + expect(parseStoragePath('///')).toEqual([]) + }) + + it('preserves spaces and unicode within a segment', () => { + expect(parseStoragePath('my folder/ünïcode 📁/x')).toEqual(['my folder', 'ünïcode 📁', 'x']) + }) +}) + +describe('serializeStoragePath', () => { + it('returns an empty string for the bucket root so clearOnDefault strips the param', () => { + expect(serializeStoragePath([])).toBe('') + }) + + it('joins segments with a slash', () => { + expect(serializeStoragePath(['a', 'b', 'c'])).toBe('a/b/c') + }) + + it('drops empty segments', () => { + expect(serializeStoragePath(['a', '', 'b'])).toBe('a/b') + }) + + it('round-trips with parseStoragePath', () => { + const segments = ['images', 'my folder', '2024'] + expect(parseStoragePath(serializeStoragePath(segments))).toEqual(segments) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx index 0459fd3c838..3ea6ed6cbfd 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx @@ -47,6 +47,19 @@ export function getPathAlongFoldersToIndex( .join('/') } +/** + * Splits a `?path` query param value into folder segments. + */ +export function parseStoragePath(value: string | null | undefined): string[] { + if (!value) return [] + return value.split('/').filter((segment) => segment.length > 0) +} + +/** Joins folder segments into `?path`; empty at the root so nuqs' `clearOnDefault` strips it. */ +export function serializeStoragePath(paths: string[]): string { + return paths.filter((segment) => segment.length > 0).join('/') +} + /** * Returns an error message string if the folder name contains invalid characters, * or null if the name is valid. diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerContent.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerContent.tsx new file mode 100644 index 00000000000..10b97962418 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerContent.tsx @@ -0,0 +1,134 @@ +import { compact, get, isEmpty, uniqBy } from 'lodash' +import { useCallback } from 'react' + +import { STORAGE_ROW_TYPES } from '../Storage.constants' +import { ConfirmDeleteModal } from './ConfirmDeleteModal' +import { CustomExpiryModal } from './CustomExpiryModal' +import { FileExplorer } from './FileExplorer' +import { FileExplorerHeader } from './FileExplorerHeader' +import { FileExplorerHeaderSelection } from './FileExplorerHeaderSelection' +import { MoveItemsModal } from './MoveItemsModal' +import { PreviewPane } from './PreviewPane' +import { useStorageExplorerNavigation } from './StorageExplorerNavigation' +import { useStorageExplorerShortcuts } from './useStorageExplorerShortcuts' +import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer' + +interface StorageExplorerContentProps { + itemSearchString: string + setItemSearchString: (value: string) => void + isLoading: boolean +} + +export const StorageExplorerContent = ({ + itemSearchString, + setItemSearchString, + isLoading, +}: StorageExplorerContentProps) => { + const { + projectRef, + columns, + openedFolders, + selectedItems, + selectedItemsToMove, + selectedBucket, + fetchMoreFolderContents, + uploadFiles, + moveFiles, + setSelectedItems, + setSelectedItemsToMove, + setIsSearching, + } = useStorageExplorerStateSnapshot() + const { truncateToColumn } = useStorageExplorerNavigation() + + const handleClearSearch = useCallback(() => { + setIsSearching(false) + setItemSearchString('') + }, [setIsSearching, setItemSearchString]) + + useStorageExplorerShortcuts({ onClearSearch: handleClearSearch }) + + /** Checkbox selection methods */ + /** [Joshen] We'll only support checkbox selection for files ONLY */ + + const onSelectAllItemsInColumn = (columnIndex: number) => { + const columnFiles = columns[columnIndex].items + .filter((item) => item.type === STORAGE_ROW_TYPES.FILE) + .map((item) => { + return { ...item, columnIndex } + }) + const columnFilesId = compact(columnFiles.map((item) => item.id)) + const selectedItemsFromColumn = selectedItems.filter( + (item) => item.id && columnFilesId.includes(item.id) + ) + + if (selectedItemsFromColumn.length === columnFiles.length) { + // Deselect all items from column + const updatedSelectedItems = selectedItems.filter( + (item) => item.id && !columnFilesId.includes(item.id) + ) + setSelectedItems(updatedSelectedItems) + } else { + // Select all items from column + const updatedSelectedItems = uniqBy(selectedItems.concat(columnFiles), 'id') + setSelectedItems(updatedSelectedItems) + } + } + + /** File manipulation methods */ + + const onFilesUpload = async (event: any, columnIndex: number = -1) => { + event.persist() + const items = event.target.files || event.dataTransfer.items + const isDrop = !isEmpty(get(event, ['dataTransfer', 'items'], [])) + await uploadFiles({ files: items, columnIndex, isDrop }) + event.target.value = '' + } + + const onMoveSelectedFiles = async (newPath: string) => { + await moveFiles(newPath) + } + + return ( + <> + {selectedItems.length === 0 ? ( + + ) : ( + + )} +
+ + fetchMoreFolderContents({ index, column, searchString: itemSearchString }) + } + /> + +
+ + + + 0} + projectRef={projectRef} + bucketId={selectedBucket.id} + bucketName={selectedBucket.name} + selectedItemsToMove={selectedItemsToMove} + openedFolders={openedFolders} + onSelectCancel={() => setSelectedItemsToMove([])} + onSelectMove={onMoveSelectedFiles} + /> + + + + ) +} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.test.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.test.tsx new file mode 100644 index 00000000000..f5639d88ee3 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.test.tsx @@ -0,0 +1,447 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import type { PropsWithChildren } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants' +import type { StorageItem } from '../Storage.types' +import { + StorageExplorerNavigationProvider, + useStorageExplorerNavigation, +} from './StorageExplorerNavigation' + +const { mockUseStorageExplorerStateSnapshot } = vi.hoisted(() => ({ + mockUseStorageExplorerStateSnapshot: vi.fn(), +})) + +vi.mock('@/state/storage-explorer', () => ({ + useStorageExplorerStateSnapshot: () => mockUseStorageExplorerStateSnapshot(), +})) + +function makeFolder(name: string): StorageItem { + return { + id: null, + name, + type: STORAGE_ROW_TYPES.FOLDER, + status: STORAGE_ROW_STATUS.READY, + metadata: null, + isCorrupted: false, + created_at: null, + updated_at: null, + last_accessed_at: null, + } +} + +function makeFile(name: string): StorageItem { + return { ...makeFolder(name), id: name, type: STORAGE_ROW_TYPES.FILE } +} + +function makeColumn(name: string, items: StorageItem[] = [], hasMoreItems = false) { + return { id: name, name, path: '', status: STORAGE_ROW_STATUS.READY, items, hasMoreItems } +} + +/** + * `openedFolders` excludes the bucket root, so a store at `images/2024` has three + * columns and two opened folders. + */ +function createSnapshot({ + openedFolders = [] as StorageItem[], + columns = [makeColumn('my-bucket')], + selectedFilePreview = undefined as any, +} = {}) { + return { + selectedBucket: { id: 'bucket-id', name: 'my-bucket' }, + columns, + openedFolders, + selectedFilePreview, + fetchFoldersByPath: vi.fn().mockResolvedValue({ missingPaths: [] }), + fetchFolderContents: vi.fn().mockResolvedValue(undefined), + openFolder: vi.fn().mockResolvedValue(undefined), + popColumn: vi.fn(), + popColumnAtIndex: vi.fn(), + popOpenedFolders: vi.fn(), + popOpenedFoldersAtIndex: vi.fn(), + clearSelectedItems: vi.fn(), + setSelectedFilePreview: vi.fn(), + } +} + +/** + * The nuqs adapter has to sit outside the provider, so the wrapper is composed here + * rather than going through `customRenderHook` (whose `wrapper` option would replace + * the adapter instead of nesting inside it). + */ +function renderWithProvider({ + searchParams = '', + searchString = '', + isBucketReady = true, + hasMemory = false, +}: { + searchParams?: string + searchString?: string + isBucketReady?: boolean + /** Lets `setSearchParams` stand in for Back/forward or a pasted link. */ + hasMemory?: boolean +} = {}) { + const onUrlUpdate = vi.fn() + let currentSearchParams = searchParams + const utils = renderHook(() => useStorageExplorerNavigation(), { + wrapper: ({ children }: PropsWithChildren) => ( + + + {children} + + + ), + }) + return { + ...utils, + onUrlUpdate, + setSearchParams: (next: string) => { + currentSearchParams = next + utils.rerender() + }, + } +} + +describe('StorageExplorerNavigation', () => { + beforeEach(() => { + mockUseStorageExplorerStateSnapshot.mockReset() + }) + + it('restores the column stack from ?path on mount', async () => { + const snapshot = createSnapshot({ columns: [] }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + renderWithProvider({ searchParams: '?path=images/2024' }) + + await waitFor(() => { + expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({ + paths: ['images', '2024'], + searchString: '', + showLoading: true, + }) + }) + expect(snapshot.fetchFoldersByPath).toHaveBeenCalledTimes(1) + }) + + it('fetches the bucket root when there is no ?path', async () => { + const snapshot = createSnapshot({ columns: [] }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + renderWithProvider() + + await waitFor(() => { + expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({ + paths: [], + searchString: '', + showLoading: true, + }) + }) + }) + + it('does not fetch until the bucket is ready', async () => { + const snapshot = createSnapshot({ columns: [] }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + renderWithProvider({ searchParams: '?path=images', isBucketReady: false }) + + await Promise.resolve() + expect(snapshot.fetchFoldersByPath).not.toHaveBeenCalled() + }) + + it('drills down through the store without re-fetching the whole path', async () => { + // Store and URL already agree on `images`; opening a child must not trigger a restore. + const snapshot = createSnapshot({ + openedFolders: [makeFolder('images')], + columns: [makeColumn('my-bucket'), makeColumn('images')], + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { result } = renderWithProvider({ searchParams: '?path=images' }) + + await act(async () => { + await result.current.openFolderAtIndex(1, makeFolder('2024')) + }) + + expect(snapshot.openFolder).toHaveBeenCalledTimes(1) + expect(snapshot.openFolder).toHaveBeenCalledWith(1, expect.objectContaining({ name: '2024' })) + expect(snapshot.fetchFoldersByPath).not.toHaveBeenCalled() + }) + + it('writes the URL when jumping to a path and lets the restore effect fetch it', async () => { + const snapshot = createSnapshot({ + openedFolders: [makeFolder('images')], + columns: [makeColumn('my-bucket'), makeColumn('images')], + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { result, onUrlUpdate } = renderWithProvider({ searchParams: '?path=images' }) + + act(() => { + result.current.navigateToPath(['archive', '2025']) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const [update] = onUrlUpdate.mock.calls.at(-1)! + expect(update.queryString).toContain('path=archive/2025') + expect(update.options.history).toBe('push') + }) + + it('falls back to the bucket root when a path segment no longer exists', async () => { + const snapshot = createSnapshot({ columns: [] }) + snapshot.fetchFoldersByPath + .mockResolvedValueOnce({ missingPaths: ['2024'] }) + .mockResolvedValue({ missingPaths: [] }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { onUrlUpdate } = renderWithProvider({ searchParams: '?path=images/2024' }) + + // Re-fetches the root rather than leaving the store on the dead path + await waitFor(() => { + expect(snapshot.fetchFoldersByPath).toHaveBeenCalledWith({ + paths: [], + searchString: '', + showLoading: true, + }) + }) + // ...and corrects the URL to match, without a history entry + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const [update] = onUrlUpdate.mock.calls.at(-1)! + expect(update.queryString).not.toContain('path=') + expect(update.options.history).toBe('replace') + }) + + it('leaves ?preview alone when the listing is incomplete', async () => { + // The file may simply be on a later page — dropping the param would lose the deep link. + const snapshot = createSnapshot({ + columns: [makeColumn('my-bucket', [makeFile('other.png')], true)], + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { onUrlUpdate } = renderWithProvider({ searchParams: '?preview=a.png' }) + + await Promise.resolve() + expect(onUrlUpdate).not.toHaveBeenCalled() + expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled() + }) + + it('resolves ?preview once a later page brings the file in', async () => { + // The file sits beyond the first LIMIT-sized page, so it is absent from the initial + // listing. The param is kept, and the preview opens when pagination loads it. + const snapshot = createSnapshot({ + columns: [makeColumn('my-bucket', [makeFile('other.png')], true)], + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { rerender } = renderWithProvider({ searchParams: '?preview=a.png' }) + expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled() + + mockUseStorageExplorerStateSnapshot.mockReturnValue({ + ...snapshot, + columns: [makeColumn('my-bucket', [makeFile('other.png'), makeFile('a.png')], false)], + }) + await act(async () => { + rerender() + }) + + expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith( + expect.objectContaining({ name: 'a.png' }) + ) + }) + + it('drops ?preview when the file is gone from a complete listing', async () => { + const snapshot = createSnapshot({ + columns: [makeColumn('my-bucket', [makeFile('other.png')])], + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { onUrlUpdate } = renderWithProvider({ searchParams: '?preview=a.png' }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const [update] = onUrlUpdate.mock.calls.at(-1)! + expect(update.queryString).not.toContain('preview=') + }) + + it('records an opened preview in ?preview without adding a history entry', async () => { + const snapshot = createSnapshot({ + columns: [makeColumn('my-bucket', [makeFile('a.png')])], + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { result, onUrlUpdate } = renderWithProvider() + + act(() => { + result.current.setPreviewedFile({ ...makeFile('a.png'), columnIndex: 0 }) + }) + + expect(snapshot.setSelectedFilePreview).toHaveBeenCalled() + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const [update] = onUrlUpdate.mock.calls.at(-1)! + expect(update.queryString).toContain('preview=a.png') + expect(update.options.history).toBe('replace') + }) + + it('restores a previewed file from ?preview once its column has loaded', async () => { + const snapshot = createSnapshot({ + columns: [makeColumn('my-bucket', [makeFile('a.png')])], + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + renderWithProvider({ searchParams: '?preview=a.png' }) + + await waitFor(() => { + expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith( + expect.objectContaining({ name: 'a.png', columnIndex: 0 }) + ) + }) + }) + + it('does not let a stale ?preview revert a freshly previewed file', async () => { + // Clicking a file mutates the store synchronously; the URL is written a render later. + // On that in-between render `?preview` still names the *previous* file, which is very + // much still in the column — so without a "which side moved" guard this effect finds + // it and pushes the preview back, undoing the click. + const columns = [makeColumn('my-bucket', [makeFile('a.png'), makeFile('b.png')])] + const snapshot = createSnapshot({ + columns, + selectedFilePreview: { ...makeFile('a.png'), columnIndex: 0 }, + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { rerender } = renderWithProvider({ searchParams: '?preview=a.png' }) + snapshot.setSelectedFilePreview.mockClear() + + // Store now previews b.png while the URL still says a.png + mockUseStorageExplorerStateSnapshot.mockReturnValue({ + ...snapshot, + selectedFilePreview: { ...makeFile('b.png'), columnIndex: 0 }, + }) + await act(async () => { + rerender() + }) + + expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled() + }) + + it('collapses the stack and records the file in a single URL write', async () => { + // The file sits in the bucket root while the store is a level deeper, so selecting it + // has to drop `path` and add `preview` at once — two writes would leave `images` + // paired with a root-level file in history. + const snapshot = createSnapshot({ + openedFolders: [makeFolder('images')], + columns: [makeColumn('my-bucket', [makeFile('a.png')]), makeColumn('images')], + }) + snapshot.popColumnAtIndex.mockImplementation((index: number) => { + snapshot.columns = snapshot.columns.slice(0, index + 1) + }) + snapshot.popOpenedFoldersAtIndex.mockImplementation((index: number) => { + snapshot.openedFolders = snapshot.openedFolders.slice(0, index + 1) + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { result, onUrlUpdate } = renderWithProvider({ searchParams: '?path=images' }) + + act(() => { + result.current.setPreviewedFile({ ...makeFile('a.png'), columnIndex: 0 }) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + expect(onUrlUpdate).toHaveBeenCalledTimes(1) + const [update] = onUrlUpdate.mock.calls.at(-1)! + expect(update.queryString).toContain('preview=a.png') + expect(update.queryString).not.toContain('path=images') + // Collapsing columns is a navigation, so Back returns to the deeper folder + expect(update.options.history).toBe('push') + expect(snapshot.popColumnAtIndex).toHaveBeenCalledWith(0) + }) + + it('applies a ?path change that lands mid-restore instead of overwriting it', async () => { + const releases: (() => void)[] = [] + const snapshot = createSnapshot({ columns: [] }) + snapshot.fetchFoldersByPath.mockImplementation(async ({ paths }: { paths: string[] }) => { + await new Promise((resolve) => releases.push(resolve)) + // A restore always lands the store on the path it was started for + snapshot.openedFolders = paths.map(makeFolder) + snapshot.columns = [makeColumn('my-bucket'), ...paths.map((path) => makeColumn(path))] + return { missingPaths: [] } + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { onUrlUpdate, rerender, setSearchParams } = renderWithProvider({ + searchParams: '?path=images/2024', + hasMemory: true, + }) + await waitFor(() => expect(snapshot.fetchFoldersByPath).toHaveBeenCalledTimes(1)) + + // Back, while the first restore is still in flight + act(() => setSearchParams('?path=images')) + await act(async () => { + releases.shift()!() + }) + act(() => rerender()) + + // Finishing the first restore must not write `images/2024` back over the newer URL + expect(onUrlUpdate).not.toHaveBeenCalled() + await waitFor(() => expect(snapshot.fetchFoldersByPath).toHaveBeenCalledTimes(2)) + expect(snapshot.fetchFoldersByPath).toHaveBeenLastCalledWith({ + paths: ['images'], + searchString: '', + showLoading: true, + }) + }) + + it('swaps the preview when ?preview names a same-named file in another folder', async () => { + const snapshot = createSnapshot({ + openedFolders: [makeFolder('archive')], + columns: [ + makeColumn('my-bucket'), + makeColumn('archive', [{ ...makeFile('photo.png'), id: 'archive/photo.png' }]), + ], + selectedFilePreview: { ...makeFile('photo.png'), id: 'images/photo.png', columnIndex: 1 }, + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + renderWithProvider({ searchParams: '?path=archive&preview=photo.png' }) + + await waitFor(() => { + expect(snapshot.setSelectedFilePreview).toHaveBeenCalledWith( + expect.objectContaining({ id: 'archive/photo.png', columnIndex: 1 }) + ) + }) + }) + + it('leaves ?preview alone while a search is narrowing the folder listing', async () => { + const snapshot = createSnapshot({ + columns: [makeColumn('my-bucket', [makeFile('other.png')])], + }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + const { onUrlUpdate } = renderWithProvider({ + searchParams: '?preview=a.png', + searchString: 'other', + }) + + await act(async () => {}) + expect(onUrlUpdate).not.toHaveBeenCalled() + expect(snapshot.setSelectedFilePreview).not.toHaveBeenCalled() + }) + + it('refetches the bucket root when switching buckets without a path', async () => { + // The provider is keyed per project, not per bucket, so the store can still hold + // the previous bucket's columns. + const snapshot = createSnapshot({ columns: [makeColumn('another-bucket')] }) + mockUseStorageExplorerStateSnapshot.mockReturnValue(snapshot) + + renderWithProvider() + + await waitFor(() => expect(snapshot.fetchFoldersByPath).toHaveBeenCalled()) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.tsx new file mode 100644 index 00000000000..17cb2e80ce2 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation.tsx @@ -0,0 +1,267 @@ +import { + createContext, + useContext, + useEffect, + useEffectEvent, + useMemo, + useRef, + type PropsWithChildren, +} from 'react' + +import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants' +import type { StorageItem, StorageItemWithColumn } from '../Storage.types' +import { getPathAlongOpenedFolders, parseStoragePath } from './StorageExplorer.utils' +import { useStorageExplorerUrlState } from './useStorageExplorerUrlState' +import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer' + +interface StorageExplorerNavigationContextValue { + openFolderAtIndex: (columnIndex: number, folder: StorageItem) => Promise + navigateToPath: (paths: string[]) => void + goUpOneLevel: () => void + truncateToColumn: (columnIndex: number) => void + setPreviewedFile: (item: StorageItemWithColumn) => void + clearPreviewedFile: () => void +} + +const StorageExplorerNavigationContext = + createContext(null) + +export const useStorageExplorerNavigation = () => { + const context = useContext(StorageExplorerNavigationContext) + if (!context) { + throw new Error( + 'useStorageExplorerNavigation must be used within a StorageExplorerNavigationProvider' + ) + } + return context +} + +interface StorageExplorerNavigationProviderProps { + isBucketReady: boolean + searchString: string +} + +/** + * Syncs `?path` / `?preview` with the valtio store, which stays authoritative. Whichever + * side moved last decides the direction — one writer each way is what stops it looping. + */ +export const StorageExplorerNavigationProvider = ({ + isBucketReady, + searchString, + children, +}: PropsWithChildren) => { + const snap = useStorageExplorerStateSnapshot() + const { urlPath, urlFolderPaths, urlPreview, setUrlLocation, setUrlPreview } = + useStorageExplorerUrlState() + + const storePath = getPathAlongOpenedFolders(snap, false) + const bucketName = snap.selectedBucket.name + const rootColumnName = snap.columns[0]?.name + const previewedFileName = snap.selectedFilePreview?.name + const previewedFileId = snap.selectedFilePreview?.id + const lastColumn = snap.columns[snap.columns.length - 1] + const isLastColumnReady = lastColumn?.status === STORAGE_ROW_STATUS.READY + + const previousStorePathRef = useRef(storePath) + const previousSearchStringRef = useRef(null) + /** Marks a store move as deliberate navigation (push), not a mutation side effect (replace). */ + const navigationHistoryModeRef = useRef<'push' | null>(null) + const previousPreviewRef = useRef(previewedFileName) + /** The `?path` a restore is in flight for; `openedFolders` only catches up when it resolves. */ + const restoringPathRef = useRef(null) + /** A `?path` change that landed mid-restore, so it isn't mistaken for a newer one. */ + const hasSupersededRestoreRef = useRef(false) + + const restoreFromUrl = useEffectEvent(async () => { + restoringPathRef.current = urlPath + hasSupersededRestoreRef.current = false + try { + const { missingPaths } = await snap.fetchFoldersByPath({ + paths: urlFolderPaths, + searchString, + showLoading: true, + }) + // Skip the fallback once a newer URL has superseded this pass; its own restore runs. + if (missingPaths.length > 0 && !hasSupersededRestoreRef.current) { + // Dead folder path: reset store and URL together, or the reconcile effect writes + // the dead path straight back. + await snap.fetchFoldersByPath({ paths: [], searchString, showLoading: true }) + setUrlLocation({ paths: [], preview: null }, { history: 'replace' }) + } + } finally { + restoringPathRef.current = null + } + }) + + const refetchForSearch = useEffectEvent(async () => { + // Only the folder you are standing in is refetched — passing the term to every open + // column (as the old fetch did) made the current folder vanish from its own parent. + const currentIndex = snap.openedFolders.length - 1 + const currentFolder = snap.openedFolders[currentIndex] + await snap.fetchFolderContents({ + bucketId: snap.selectedBucket.id, + folderId: currentFolder?.id ?? snap.selectedBucket.id, + folderName: currentFolder?.name ?? snap.selectedBucket.name, + index: currentFolder ? currentIndex : -1, + searchString, + }) + }) + + const reconcileUrlToStore = useEffectEvent((history: 'push' | 'replace') => { + setUrlLocation( + { paths: parseStoragePath(storePath), preview: snap.selectedFilePreview?.name ?? null }, + { history } + ) + }) + + useEffect(() => { + if (!isBucketReady) return + if (restoringPathRef.current !== null) { + // Hold on to a URL change that landed mid-restore; the pass after it is where it applies. + if (urlPath !== restoringPathRef.current) hasSupersededRestoreRef.current = true + return + } + + const hasStoreChanged = storePath !== previousStorePathRef.current + previousStorePathRef.current = storePath + + const hasSearchChanged = searchString !== previousSearchStringRef.current + previousSearchStringRef.current = searchString + + // A superseded restore means the URL, not the store, is the side that moved last. + const hasUrlSupersededStore = hasSupersededRestoreRef.current + hasSupersededRestoreRef.current = false + + // Nothing loaded yet, or the store still holds the bucket we navigated away from — + // the provider is keyed per project, so a bucket switch does not remount it. + const isStoreEmpty = snap.columns.length === 0 + const isStoreOnAnotherBucket = rootColumnName !== bucketName + if (isStoreEmpty || isStoreOnAnotherBucket) { + restoreFromUrl() + return + } + + if (storePath !== urlPath) { + if (hasStoreChanged && !hasUrlSupersededStore) { + const history = navigationHistoryModeRef.current ?? 'replace' + navigationHistoryModeRef.current = null + reconcileUrlToStore(history) + return + } + restoreFromUrl() + return + } + + navigationHistoryModeRef.current = null + + if (hasSearchChanged) refetchForSearch() + }, [ + isBucketReady, + urlPath, + storePath, + searchString, + bucketName, + rootColumnName, + snap.columns.length, + ]) + + // Restoring `?preview` needs the full item, which only exists once its column has loaded. + useEffect(() => { + if (!isBucketReady || !isLastColumnReady) return + + const hasPreviewChangedInStore = previewedFileName !== previousPreviewRef.current + previousPreviewRef.current = previewedFileName + if (hasPreviewChangedInStore) return + + if (!urlPreview) { + if (previewedFileName) snap.setSelectedFilePreview(undefined) + return + } + + const item = lastColumn.items.find( + (columnItem) => columnItem.name === urlPreview && columnItem.type === STORAGE_ROW_TYPES.FILE + ) + if (!item) { + const isListingComplete = !searchString && !lastColumn.hasMoreItems + if (isListingComplete) setUrlPreview(null) + return + } + + if (previewedFileId === item.id) return + + snap.setSelectedFilePreview({ ...item, columnIndex: snap.columns.length - 1 }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isBucketReady, + isLastColumnReady, + urlPreview, + previewedFileName, + previewedFileId, + lastColumn?.path, + lastColumn?.items.length, + searchString, + ]) + + const context = useMemo(() => { + const openFolderAtIndex = async (columnIndex: number, folder: StorageItem) => { + navigationHistoryModeRef.current = 'push' + await snap.openFolder(columnIndex, folder) + } + + const goUpOneLevel = () => { + navigationHistoryModeRef.current = 'push' + snap.popColumn() + snap.popOpenedFolders() + snap.setSelectedFilePreview(undefined) + } + + const truncateToColumn = (columnIndex: number) => { + navigationHistoryModeRef.current = 'push' + snap.popColumnAtIndex(columnIndex) + snap.popOpenedFoldersAtIndex(columnIndex - 1) + snap.setSelectedFilePreview(undefined) + snap.clearSelectedItems() + } + + const navigateToPath = (paths: string[]) => { + setUrlLocation({ paths, preview: null }, { history: 'push' }) + } + + const setPreviewedFile = (item: StorageItemWithColumn) => { + const paths = snap.openedFolders.slice(0, item.columnIndex).map((folder) => folder.name) + // Collapsing back to the file's own column is a navigation; previewing in place isn't. + const isCollapsingColumns = item.columnIndex < snap.openedFolders.length + + snap.popColumnAtIndex(item.columnIndex) + snap.popOpenedFoldersAtIndex(item.columnIndex - 1) + snap.clearSelectedItems() + snap.setSelectedFilePreview(item) + // One write, so the URL never pairs the new file with the old, deeper path — a + // separate `preview` write would leave exactly that pairing behind in history. + setUrlLocation( + { paths, preview: item.name }, + { history: isCollapsingColumns ? 'push' : 'replace' } + ) + } + + const clearPreviewedFile = () => { + snap.setSelectedFilePreview(undefined) + setUrlPreview(null) + } + + return { + openFolderAtIndex, + navigateToPath, + goUpOneLevel, + truncateToColumn, + setPreviewedFile, + clearPreviewedFile, + } + }, [setUrlLocation, setUrlPreview, snap]) + + return ( + + {children} + + ) +} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerShortcuts.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerShortcuts.ts index cbc863da12f..156f8f533ad 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerShortcuts.ts +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerShortcuts.ts @@ -1,5 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useStorageExplorerNavigation } from './StorageExplorerNavigation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' @@ -24,12 +25,13 @@ export function useStorageExplorerShortcuts({ onClearSearch }: UseStorageExplore clearSelectedItems, setSelectedItemsToDelete, setSelectedItemsToMove, - setSelectedFilePreview, downloadFile, downloadSelectedFiles, refreshAll, } = useStorageExplorerStateSnapshot() + const { clearPreviewedFile } = useStorageExplorerNavigation() + const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') useShortcut(SHORTCUT_IDS.STORAGE_EXPLORER_REFRESH, () => { @@ -64,13 +66,9 @@ export function useStorageExplorerShortcuts({ onClearSearch }: UseStorageExplore enabled: selectedItems.length > 0, }) - useShortcut( - SHORTCUT_IDS.STORAGE_EXPLORER_CLOSE_PREVIEW, - () => setSelectedFilePreview(undefined), - { - enabled: selectedItems.length === 0 && !!selectedFilePreview, - } - ) + useShortcut(SHORTCUT_IDS.STORAGE_EXPLORER_CLOSE_PREVIEW, clearPreviewedFile, { + enabled: selectedItems.length === 0 && !!selectedFilePreview, + }) useShortcut(SHORTCUT_IDS.STORAGE_EXPLORER_CLOSE_SEARCH, onClearSearch, { enabled: selectedItems.length === 0 && !selectedFilePreview && isSearching, diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerUrlState.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerUrlState.ts new file mode 100644 index 00000000000..f0e51199bc1 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useStorageExplorerUrlState.ts @@ -0,0 +1,44 @@ +import { parseAsString, useQueryStates, type Options } from 'nuqs' +import { useMemo } from 'react' + +import { parseStoragePath, serializeStoragePath } from './StorageExplorer.utils' + +/** + * Owns the two query params that address a location inside a bucket: + * - `path` — the slash-joined folder path, e.g. `?path=images/2024` + * - `preview` — the name of the file whose preview panel is open in that folder + * + * Both live in a single `useQueryStates` so that opening a folder can clear `preview` + * and set `path` in one atomic URL write. Two separate `useQueryState` calls would push two + * history entries per drill-down, which makes the Back button useless. + * + * `path` pushes history — Back should walk back up the folder chain. `preview` replaces, + * because opening or closing a panel is not a navigation. + */ +export function useStorageExplorerUrlState() { + const [{ path, preview }, setParams] = useQueryStates( + { path: parseAsString.withDefault(''), preview: parseAsString.withDefault('') }, + { history: 'push', clearOnDefault: true } + ) + + return useMemo( + () => ({ + urlPath: path, + urlFolderPaths: parseStoragePath(path), + urlPreview: preview, + + /** + * Writes both params at once. `preview` is always passed explicitly so a location + * write can carry an open preview along with it rather than clearing it. + */ + setUrlLocation: ( + { paths, preview: previewedName }: { paths: string[]; preview: string | null }, + options?: Options + ) => setParams({ path: serializeStoragePath(paths), preview: previewedName ?? '' }, options), + + setUrlPreview: (name: string | null) => + setParams({ preview: name ?? '' }, { history: 'replace' }), + }), + [path, preview, setParams] + ) +} diff --git a/e2e/studio/utils/storage-helpers.ts b/e2e/studio/utils/storage-helpers.ts index 5da6e9b56c6..b881b0ee08e 100644 --- a/e2e/studio/utils/storage-helpers.ts +++ b/e2e/studio/utils/storage-helpers.ts @@ -176,7 +176,9 @@ export const uploadFile = async (page: Page, filePath: string, fileName: string) const fileInput = page.locator('input[type="file"]') await fileInput.setInputFiles(filePath) - await expect(page.getByRole('status')).not.toBeVisible() + // Wait out the upload progress toast. Scoped to the toast itself — a page-wide + // `getByRole('status')` also matches any live region the explorer renders. + await expect(page.locator('[data-sonner-toast]')).not.toBeVisible() // Verify file appears in the explorer by title await expect( page.getByTitle(fileName), From 09f36316a7e6cee40a5ccaacaefe07fb4e9fd627 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 18 Sep 2026 10:28:45 +0200 Subject: [PATCH 06/31] test(studio): cover storage explorer deep links end to end (#50478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | | PR | Base | Branch | | --- | --- | --- | --- | | 1 | #50476 | `master` | pre-existing correctness fixes | | 2 | #50413 | `fix/storage-explorer-listing-and-scroll` | `?path`/`?preview` deep-linking | | 3 | **this PR** | `feat/storage-nav-improvement` | end-to-end deep-link test | | 4 | #50480 | `test/storage-deep-link-e2e` | copy path / copy link row actions | ## What is the current behavior? The `?path` / `?preview` deep-linking added in #50413 has no end-to-end coverage. It is exercised by unit and component tests, but nothing verifies the real round trip through a browser and a live Storage backend. ## What is the new behavior? One spec that drills into a nested folder and asserts `?path`, opens a file and asserts `?preview`, reloads to confirm the deep link restores the same location rather than dropping back to the bucket root, then goes Back to confirm it walks up one level. Assertions go through `toHaveURL((url) => url.searchParams.get(...) === ...)` rather than a regex over the whole URL, so dots and other regex metacharacters in file names are compared literally. ## Additional context Kept on its own branch for one reason: **this spec has never been executed.** It was written without a local Supabase stack available to run it against, so its first real run is here in CI (`studio-e2e-test.yml`, `framework: [next, tanstack]` × 2 shards). Isolating it means iterating on it cannot hold up the feature work in #50413 below. Expect this one to need a pass or two. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DB6KEERERPWtLL4SY2RX4Q ## Summary by CodeRabbit * **Tests** * Improved end-to-end coverage for storage workflows, including bucket management, file and folder operations, uploads, downloads, search, and deep links. * Added reliable cleanup for test-created storage data, including when navigation, uploads, or assertions fail. * Continued verification of storage navigation and file and folder management behavior. --------- Co-authored-by: Claude Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com> --- e2e/studio/features/storage.spec.ts | 224 +++++++++++++++++++++++----- 1 file changed, 189 insertions(+), 35 deletions(-) diff --git a/e2e/studio/features/storage.spec.ts b/e2e/studio/features/storage.spec.ts index 8c98463cd24..a6acb389cd3 100644 --- a/e2e/studio/features/storage.spec.ts +++ b/e2e/studio/features/storage.spec.ts @@ -19,7 +19,7 @@ import { createBucket as createBucketViaApi, deleteBucket as deleteBucketViaApi, } from '../utils/storage/index.js' -import { test } from '../utils/test.js' +import { test, withSetupCleanup } from '../utils/test.js' import { waitForApiResponse } from '../utils/wait-for-response.js' const bucketNamePrefix = 'pw_bucket' @@ -42,7 +42,14 @@ test.describe('Storage', () => { test('can create a private bucket', async ({ page, ref }) => { const bucketName = `${bucketNamePrefix}_private` - await deleteBucketViaApi(bucketName) + await using _ = await withSetupCleanup( + async () => { + // Nothing + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await createBucket(page, ref, bucketName, false) // Verify it's marked as private (no "Public" badge should be visible) @@ -57,7 +64,14 @@ test.describe('Storage', () => { test('can create a public bucket', async ({ page, ref }) => { const bucketName = `${bucketNamePrefix}_public` - await deleteBucketViaApi(bucketName) + await using _ = await withSetupCleanup( + async () => { + // Nothing + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await createBucket(page, ref, bucketName, true) // Verify it's marked as public - wait for the badge to appear @@ -75,8 +89,14 @@ test.describe('Storage', () => { const bucketName = `${bucketNamePrefix}_edit` // Create a fresh private bucket via API - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) // Navigate to the bucket @@ -109,8 +129,14 @@ test.describe('Storage', () => { const bucketName = `${bucketNamePrefix}_delbkt` // Create a bucket via API - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) // Delete it via UI @@ -128,10 +154,16 @@ test.describe('Storage', () => { const bucketName2 = `${bucketNamePrefix}_search_2` // Create two buckets via API - await deleteBucketViaApi(bucketName1) - await deleteBucketViaApi(bucketName2) - await createBucketViaApi(bucketName1, false) - await createBucketViaApi(bucketName2, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName1, false) + await createBucketViaApi(bucketName2, false) + }, + async () => { + await deleteBucketViaApi(bucketName1) + await deleteBucketViaApi(bucketName2) + } + ) await navigateToStorageFiles(page, ref) // Search for first bucket @@ -167,8 +199,14 @@ test.describe('Storage', () => { const fileName = 'test-file.txt' // Create a bucket via API and navigate to it - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) @@ -182,8 +220,14 @@ test.describe('Storage', () => { const folderName = 'test_folder' // Create a bucket via API and navigate to it - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) @@ -191,14 +235,76 @@ test.describe('Storage', () => { await createFolder(page, folderName) }) + test('deep links to a nested folder and file via the URL', async ({ page, ref }) => { + const bucketName = `${bucketNamePrefix}_deeplink` + const folderName = 'deeplink_folder' + const fileName = 'test-file.txt' + + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) + await navigateToStorageFiles(page, ref) + await navigateToBucket(page, ref, bucketName) + + // Creating a folder drills into it, so the upload lands inside the folder + await createFolder(page, folderName) + await expect(page.getByText('Drop your files here')).toBeVisible() + const filePath = path.join(import.meta.dirname, 'files', fileName) + await uploadFile(page, filePath, fileName) + + // Reload at the bucket root so opening the folder is a real navigation rather than + // the store drilling in on its own — only the former pushes a history entry + await navigateToStorageFiles(page, ref) + await navigateToBucket(page, ref, bucketName) + await page.getByTitle(folderName).click() + // Compare the parsed params rather than regex-matching the URL, so dots and other + // regex metacharacters in file names are treated literally + await expect(page).toHaveURL((url) => url.searchParams.get('path') === folderName) + + // Opening the file records it in the URL alongside the folder + await page.getByTitle(fileName).click() + await expect(page).toHaveURL((url) => url.searchParams.get('path') === folderName) + await expect(page).toHaveURL((url) => url.searchParams.get('preview') === fileName) + + // A reload restores the same location rather than dropping back to bucket root + const deepLink = page.url() + await page.reload() + await expect( + page.getByTitle(fileName), + 'File should still be visible after reloading the deep link' + ).toBeVisible() + expect(page.url()).toBe(deepLink) + + // Back walks up out of the folder + await page.goBack() + await expect(page).toHaveURL((url) => url.searchParams.get('path') === null) + await expect( + page.getByTitle(folderName), + 'Should be back at bucket root showing the folder' + ).toBeVisible() + + await deleteBucketViaApi(bucketName) + }) + test('can rename a file', async ({ page, ref }) => { const bucketName = `${bucketNamePrefix}_rename_file` const fileName = 'test-file.txt' const newFileName = 'renamed-file.txt' // Create a bucket via API, navigate to it, and upload a file - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) @@ -215,8 +321,14 @@ test.describe('Storage', () => { const newFolderName = 'new_folder' // Create a bucket via API, navigate to it, and create a folder - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) @@ -235,8 +347,14 @@ test.describe('Storage', () => { const folderFilePath = path.join(import.meta.dirname, 'files', folderFileName) // Create a bucket via API and navigate to it - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, true) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, true) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) await uploadFile(page, rootFilePath, rootFileName) @@ -306,8 +424,14 @@ test.describe('Storage', () => { const folderName = 'folder_to_rename' // Create a bucket via API, navigate to it, and create a folder - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) await createFolder(page, folderName) @@ -341,8 +465,14 @@ test.describe('Storage', () => { const folderName = 'folder_to_blur' // Create a bucket via API, navigate to it, and create a folder - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) await createFolder(page, folderName) @@ -377,10 +507,16 @@ test.describe('Storage', () => { const fileName = 'test-file.txt' // Create 2 bucket via API, navigate to the first - await deleteBucketViaApi(bucketName) - await deleteBucketViaApi(bucketName2) - await createBucketViaApi(bucketName, false) - await createBucketViaApi(bucketName2, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + await createBucketViaApi(bucketName2, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + await deleteBucketViaApi(bucketName2) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) @@ -403,8 +539,14 @@ test.describe('Storage', () => { const fileName = 'test-file.txt' // Create a bucket via API, navigate to it, and upload a file - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) @@ -420,8 +562,14 @@ test.describe('Storage', () => { const folderName = 'test_folder' // Create a bucket via API, navigate to it, and create a folder - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) await createFolder(page, folderName) @@ -435,8 +583,14 @@ test.describe('Storage', () => { const fileName = 'test-file.txt' // Create a bucket via API, navigate to it, and upload a file - await deleteBucketViaApi(bucketName) - await createBucketViaApi(bucketName, false) + await using _ = await withSetupCleanup( + async () => { + await createBucketViaApi(bucketName, false) + }, + async () => { + await deleteBucketViaApi(bucketName) + } + ) await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) From c92ed0b219bcbef2df522506c19e89de2b4a7bf0 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 18 Sep 2026 11:40:10 +0300 Subject: [PATCH 07/31] docs: add usage examples and build-your-own middleware partials (#50461) ## See the changes * https://docs-git-docs-middleware-usage-examples-supabase.vercel.app/docs/reference/middleware/usage-examples * https://docs-git-docs-middleware-usage-examples-supabase.vercel.app/docs/reference/middleware/build-your-own ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Docs update. ## What is the current behavior? The `@supabase/middleware` reference has two hand-written pages, Introduction and Installing, followed directly by the generated API reference. There is no worked example of composing middleware and no guidance on writing one. The authoring guide lives only in the [middleware repo](https://github.com/supabase/middleware/blob/main/docs/authoring-guide.md). ## What is the new behavior? Two new partials sit between Installing and the generated reference: - **Usage examples**: two `pipeline` examples. The first composes `withCors` and `withFeatureFlag` from `@supabase/middleware`. The second adds `withSupabase` from `@supabase/server`: `withCors` first, `withSupabase({ auth: 'user', cors: 'disabled' })` second, and an environment-driven flag last. The prose explains why a CORS layer must precede the auth gate, what `withSupabase` does for CORS on its own, and that the entry form of `withSupabase` is alpha and needs `@supabase/server` 1.6.0 or later. - **Build your own middleware**: the `defineMiddleware` shape (four type arguments, when `run` receives the config, contribute vs short-circuit, reading `getEnv` inside the per-request function), composing a custom entry in `pipeline`, what `pipeline` checks at compile time, and when `satisfies FetchHandler` matters. It links to the full authoring guide for tests, packaging, and the variants. `partialsOrder` in `spec/reference/middleware/v1/config.json` registers both partials. The `docs/ref/middleware/` mirrors were generated with `pnpm codegen:references:new`. ## Additional context - Every snippet typechecks against `@supabase/middleware` and `@supabase/server` source on `main`. The "fails to compile" statements were confirmed with negative typechecks (duplicate key, unmet prerequisite, in both the `pipeline` and nested forms). - The second example's request flow was exercised end to end with a local JWKS: preflight `204`, missing credentials `401`, flag off `404`, flag on `200`, and the reversed order producing a `401` with no CORS headers. - The generated `sections.json` lists the four partials in order: Introduction, Installing, Usage examples, Build your own middleware. No local render check was done. ## Summary by CodeRabbit * **Documentation** * Added usage examples for composing middleware pipelines with CORS, feature flags, authentication, and Supabase. * Added guidance for creating custom middleware, contributing request context, handling responses, and accessing runtime environment variables. * Documented middleware ordering, validation, preflight handling, and authentication behavior. --- .../docs/ref/middleware/build-your-own.mdx | 70 +++++++++++++++ .../docs/ref/middleware/usage-examples.mdx | 88 +++++++++++++++++++ .../spec/reference/middleware/v1/config.json | 2 +- .../middleware/v1/partials/build-your-own.mdx | 70 +++++++++++++++ .../middleware/v1/partials/usage-examples.mdx | 88 +++++++++++++++++++ 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 apps/docs/docs/ref/middleware/build-your-own.mdx create mode 100644 apps/docs/docs/ref/middleware/usage-examples.mdx create mode 100644 apps/docs/spec/reference/middleware/v1/partials/build-your-own.mdx create mode 100644 apps/docs/spec/reference/middleware/v1/partials/usage-examples.mdx diff --git a/apps/docs/docs/ref/middleware/build-your-own.mdx b/apps/docs/docs/ref/middleware/build-your-own.mdx new file mode 100644 index 00000000000..b5ee46fca58 --- /dev/null +++ b/apps/docs/docs/ref/middleware/build-your-own.mdx @@ -0,0 +1,70 @@ +--- +id: build-your-own +title: Build your own middleware +--- + +A middleware is a `withFoo` function built with `defineMiddleware`. It owns one key on `ctx` and runs before the handler on every request. A generator `run` can also act on the response on the way out; the guide calls that the response seam. This page covers the shape. The [authoring guide](https://github.com/supabase/middleware/blob/main/docs/authoring-guide.md) in the repo covers tests, packaging, and the variants: requiring an upstream key, a config callback that reads upstream context, a hand-written signature, wrapping a vendor SDK, the response seam, and bundling several middleware into one. + +### Define it + + + + + `defineMiddleware` takes four type arguments and a spec object. The last two have defaults, but pass all four: without the fourth, the contribution lands on `ctx` as `unknown`. + + The type arguments are the key, the config type, the upstream context the middleware needs, and the contribution type. `void` config means the middleware takes no options. `Record` means it needs nothing from earlier middleware. + + `run` receives the config when the stack is built and returns the per-request function. That function receives the request and the upstream `ctx`. It contributes by returning an object with the key, or short-circuits by returning a `Response`. Read `getEnv` inside the per-request function, not in the outer stage: on Cloudflare Workers the environment arrives with each request. + + + + + + ```ts with-request-id.ts + import { defineMiddleware } from '@supabase/middleware' + + export const withRequestId = defineMiddleware< + 'requestId', + void, + Record, + string + >({ + key: 'requestId', + run: () => async (req) => ({ + requestId: req.headers.get('x-request-id') ?? crypto.randomUUID(), + }), + }) + ``` + + + + +### Compose it + + + + + Your middleware drops into the same `pipeline` array as the built-in ones. The handler reads `ctx.requestId` as a `string`, inferred from the entries. + + `pipeline` checks the array at compile time. Two entries that contribute the same key fail with an error naming the key. An entry whose prerequisite no earlier entry supplies fails the same way. If you nest calls instead of using `pipeline`, keep `satisfies FetchHandler` on the outermost call; without that anchor, a nested stack with a duplicate key or a missing prerequisite compiles. `pipeline` already returns a `FetchHandler`, so the anchor adds nothing there. + + + + + + ```ts + import { pipeline } from '@supabase/middleware' + import { withCors } from '@supabase/middleware/cors' + import { withRequestId } from './with-request-id' + + export default { + fetch: pipeline( + [withCors({}), withRequestId()], + async (_req, ctx) => + Response.json({ ok: true }, { headers: { 'x-request-id': ctx.requestId } }), + ), + } + ``` + + + diff --git a/apps/docs/docs/ref/middleware/usage-examples.mdx b/apps/docs/docs/ref/middleware/usage-examples.mdx new file mode 100644 index 00000000000..4a557683d32 --- /dev/null +++ b/apps/docs/docs/ref/middleware/usage-examples.mdx @@ -0,0 +1,88 @@ +--- +id: usage-examples +title: Usage examples +--- + +Each example builds one Fetch handler with `pipeline`. Entries run in array order on the request. The handler runs last and reads what the entries contributed to `ctx`. + +### Gate a route behind CORS and a feature flag + + + + + `withCors` runs first. It answers the CORS preflight (an `OPTIONS` request carrying `Access-Control-Request-Method`) with `204` before anything else runs. On the way out, it stamps `Access-Control-*` headers onto the response when the request's `Origin` is allowed. + + `withFeatureFlag` runs second. `evaluate` receives the request and decides: `true` admits it, `false` rejects it. It can be async, and it can return a verdict object instead of a boolean. A rejected request gets a `404` and never reaches the handler. An admitted request reaches the handler with `ctx.featureFlag` set. + + Both middleware ship in `@supabase/middleware`. The handler is plain Fetch, so the same stack runs on Node, Deno, Bun, and Cloudflare Workers; only the host entry point differs. + + + + + + ```ts + import { pipeline } from '@supabase/middleware' + import { withCors } from '@supabase/middleware/cors' + import { withFeatureFlag } from '@supabase/middleware/feature-flag' + + export default { + fetch: pipeline( + [ + withCors({ origin: ['https://app.example.com'], credentials: true }), + withFeatureFlag({ + name: 'beta-checkout', + evaluate: (req) => req.headers.get('x-beta') === '1', + }), + ], + async (_req, ctx) => Response.json({ feature: ctx.featureFlag.name }), + ), + } + ``` + + + + +### Roll out an authenticated endpoint behind a flag + + + + + Middleware from [`@supabase/server`](/docs/reference/server/introduction) drop into the same array. `withCors` runs first, so the preflight is answered before the auth gate. `withSupabase` runs second with `cors: 'disabled'`, because `withCors` owns CORS here. It verifies the caller's JWT and puts an RLS-scoped client on `ctx.supabase`. A request without valid credentials gets a `401` and never reaches the flag or the handler. + + The flag runs last. `evaluate` reads an environment variable through `getEnv`, so the endpoint returns `404` to every signed-in caller until `BETA_CHECKOUT` is set to `on`. Flip the variable to roll the endpoint out. + + Without `withCors`, `withSupabase` answers every `OPTIONS` request itself with `204` and wildcard CORS headers (`Access-Control-Allow-Origin: *`). That is enough when you do not need an origin allowlist. A layer that owns CORS must sit before `withSupabase` in the array. Placed after it, the preflight reaches the auth gate and gets a `401`. + + The entry form of `withSupabase` is alpha. It needs `@supabase/server` 1.6.0 or later. + + + + + + ```ts + import { getEnv, pipeline } from '@supabase/middleware' + import { withCors } from '@supabase/middleware/cors' + import { withFeatureFlag } from '@supabase/middleware/feature-flag' + import { withSupabase } from '@supabase/server' + + export default { + fetch: pipeline( + [ + withCors({ origin: ['https://app.example.com'] }), + withSupabase({ auth: 'user', cors: 'disabled' }), + withFeatureFlag({ + name: 'beta-checkout', + evaluate: () => getEnv('BETA_CHECKOUT') === 'on', + }), + ], + async (_req, ctx) => { + const { data, error } = await ctx.supabase.from('carts').select() + if (error) return Response.json({ error: 'query_failed' }, { status: 500 }) + return Response.json(data) + }, + ), + } + ``` + + + diff --git a/apps/docs/spec/reference/middleware/v1/config.json b/apps/docs/spec/reference/middleware/v1/config.json index 74fca13b72c..0e0a6cafb76 100644 --- a/apps/docs/spec/reference/middleware/v1/config.json +++ b/apps/docs/spec/reference/middleware/v1/config.json @@ -1,5 +1,5 @@ { "categoryOrder": ["Composition", "Middleware", "Environment", "Types"], - "partialsOrder": ["introduction", "installing"], + "partialsOrder": ["introduction", "installing", "usage-examples", "build-your-own"], "navigationPrefixes": {} } diff --git a/apps/docs/spec/reference/middleware/v1/partials/build-your-own.mdx b/apps/docs/spec/reference/middleware/v1/partials/build-your-own.mdx new file mode 100644 index 00000000000..b5ee46fca58 --- /dev/null +++ b/apps/docs/spec/reference/middleware/v1/partials/build-your-own.mdx @@ -0,0 +1,70 @@ +--- +id: build-your-own +title: Build your own middleware +--- + +A middleware is a `withFoo` function built with `defineMiddleware`. It owns one key on `ctx` and runs before the handler on every request. A generator `run` can also act on the response on the way out; the guide calls that the response seam. This page covers the shape. The [authoring guide](https://github.com/supabase/middleware/blob/main/docs/authoring-guide.md) in the repo covers tests, packaging, and the variants: requiring an upstream key, a config callback that reads upstream context, a hand-written signature, wrapping a vendor SDK, the response seam, and bundling several middleware into one. + +### Define it + + + + + `defineMiddleware` takes four type arguments and a spec object. The last two have defaults, but pass all four: without the fourth, the contribution lands on `ctx` as `unknown`. + + The type arguments are the key, the config type, the upstream context the middleware needs, and the contribution type. `void` config means the middleware takes no options. `Record` means it needs nothing from earlier middleware. + + `run` receives the config when the stack is built and returns the per-request function. That function receives the request and the upstream `ctx`. It contributes by returning an object with the key, or short-circuits by returning a `Response`. Read `getEnv` inside the per-request function, not in the outer stage: on Cloudflare Workers the environment arrives with each request. + + + + + + ```ts with-request-id.ts + import { defineMiddleware } from '@supabase/middleware' + + export const withRequestId = defineMiddleware< + 'requestId', + void, + Record, + string + >({ + key: 'requestId', + run: () => async (req) => ({ + requestId: req.headers.get('x-request-id') ?? crypto.randomUUID(), + }), + }) + ``` + + + + +### Compose it + + + + + Your middleware drops into the same `pipeline` array as the built-in ones. The handler reads `ctx.requestId` as a `string`, inferred from the entries. + + `pipeline` checks the array at compile time. Two entries that contribute the same key fail with an error naming the key. An entry whose prerequisite no earlier entry supplies fails the same way. If you nest calls instead of using `pipeline`, keep `satisfies FetchHandler` on the outermost call; without that anchor, a nested stack with a duplicate key or a missing prerequisite compiles. `pipeline` already returns a `FetchHandler`, so the anchor adds nothing there. + + + + + + ```ts + import { pipeline } from '@supabase/middleware' + import { withCors } from '@supabase/middleware/cors' + import { withRequestId } from './with-request-id' + + export default { + fetch: pipeline( + [withCors({}), withRequestId()], + async (_req, ctx) => + Response.json({ ok: true }, { headers: { 'x-request-id': ctx.requestId } }), + ), + } + ``` + + + diff --git a/apps/docs/spec/reference/middleware/v1/partials/usage-examples.mdx b/apps/docs/spec/reference/middleware/v1/partials/usage-examples.mdx new file mode 100644 index 00000000000..4a557683d32 --- /dev/null +++ b/apps/docs/spec/reference/middleware/v1/partials/usage-examples.mdx @@ -0,0 +1,88 @@ +--- +id: usage-examples +title: Usage examples +--- + +Each example builds one Fetch handler with `pipeline`. Entries run in array order on the request. The handler runs last and reads what the entries contributed to `ctx`. + +### Gate a route behind CORS and a feature flag + + + + + `withCors` runs first. It answers the CORS preflight (an `OPTIONS` request carrying `Access-Control-Request-Method`) with `204` before anything else runs. On the way out, it stamps `Access-Control-*` headers onto the response when the request's `Origin` is allowed. + + `withFeatureFlag` runs second. `evaluate` receives the request and decides: `true` admits it, `false` rejects it. It can be async, and it can return a verdict object instead of a boolean. A rejected request gets a `404` and never reaches the handler. An admitted request reaches the handler with `ctx.featureFlag` set. + + Both middleware ship in `@supabase/middleware`. The handler is plain Fetch, so the same stack runs on Node, Deno, Bun, and Cloudflare Workers; only the host entry point differs. + + + + + + ```ts + import { pipeline } from '@supabase/middleware' + import { withCors } from '@supabase/middleware/cors' + import { withFeatureFlag } from '@supabase/middleware/feature-flag' + + export default { + fetch: pipeline( + [ + withCors({ origin: ['https://app.example.com'], credentials: true }), + withFeatureFlag({ + name: 'beta-checkout', + evaluate: (req) => req.headers.get('x-beta') === '1', + }), + ], + async (_req, ctx) => Response.json({ feature: ctx.featureFlag.name }), + ), + } + ``` + + + + +### Roll out an authenticated endpoint behind a flag + + + + + Middleware from [`@supabase/server`](/docs/reference/server/introduction) drop into the same array. `withCors` runs first, so the preflight is answered before the auth gate. `withSupabase` runs second with `cors: 'disabled'`, because `withCors` owns CORS here. It verifies the caller's JWT and puts an RLS-scoped client on `ctx.supabase`. A request without valid credentials gets a `401` and never reaches the flag or the handler. + + The flag runs last. `evaluate` reads an environment variable through `getEnv`, so the endpoint returns `404` to every signed-in caller until `BETA_CHECKOUT` is set to `on`. Flip the variable to roll the endpoint out. + + Without `withCors`, `withSupabase` answers every `OPTIONS` request itself with `204` and wildcard CORS headers (`Access-Control-Allow-Origin: *`). That is enough when you do not need an origin allowlist. A layer that owns CORS must sit before `withSupabase` in the array. Placed after it, the preflight reaches the auth gate and gets a `401`. + + The entry form of `withSupabase` is alpha. It needs `@supabase/server` 1.6.0 or later. + + + + + + ```ts + import { getEnv, pipeline } from '@supabase/middleware' + import { withCors } from '@supabase/middleware/cors' + import { withFeatureFlag } from '@supabase/middleware/feature-flag' + import { withSupabase } from '@supabase/server' + + export default { + fetch: pipeline( + [ + withCors({ origin: ['https://app.example.com'] }), + withSupabase({ auth: 'user', cors: 'disabled' }), + withFeatureFlag({ + name: 'beta-checkout', + evaluate: () => getEnv('BETA_CHECKOUT') === 'on', + }), + ], + async (_req, ctx) => { + const { data, error } = await ctx.supabase.from('carts').select() + if (error) return Response.json({ error: 'query_failed' }, { status: 500 }) + return Response.json(data) + }, + ), + } + ``` + + + From cdbe2963fa1e91743f02248daa439dd573abaecc Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 18 Sep 2026 17:13:44 +0800 Subject: [PATCH 08/31] 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 && ( + + + + )}
From 47a532eef7545db84e790965e1061da76214967b Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 18 Sep 2026 11:38:38 +0200 Subject: [PATCH 09/31] feat(studio): add copy path and copy link row actions (#50480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | | PR | Base | Branch | | --- | --- | --- | --- | | 1 | #50476 | `master` | pre-existing correctness fixes | | 2 | #50413 | `fix/storage-explorer-listing-and-scroll` | `?path`/`?preview` deep-linking | | 3 | #50478 | `feat/storage-nav-improvement` | end-to-end deep-link test | | 4 | **this PR** | `test/storage-deep-link-e2e` | copy path / copy link row actions | To read the whole change in one view: ```bash git diff master...feat/storage-copy-row-actions -- apps/studio e2e ``` ## What is the new behavior? Both row menus now offer two actions: - **Copy relative path** — the bucket-relative object key, i.e. what `storage.from(bucket)` takes - **Copy link** — the dashboard URL that reopens the item in the explorer **Copy path to folder** is replaced by **Copy relative path**. It produces the same value for a folder and now works for files too, so nothing is lost. ## Summary by CodeRabbit * **New Features** * Storage Explorer now provides separate actions to copy a relative path or a direct link for files and folders. * Copied links open the relevant storage location, including folder navigation and file preview details. * Success notifications appear after clipboard copying completes. * **Tests** * Added coverage for file and folder copy actions, generated paths and links, URL encoding, and clipboard behavior. Co-authored-by: Claude --- .../StorageExplorer/FileExplorerRow.test.tsx | 93 ++++++++++++ .../StorageExplorer/FileExplorerRow.tsx | 41 ++++-- .../StorageExplorer.utils.test.ts | 136 +++++++++++++++++- .../StorageExplorer/StorageExplorer.utils.tsx | 59 +++++++- 4 files changed, 314 insertions(+), 15 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.test.tsx diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.test.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.test.tsx new file mode 100644 index 00000000000..891821c075d --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.test.tsx @@ -0,0 +1,93 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { + STORAGE_ROW_STATUS, + STORAGE_ROW_TYPES, + STORAGE_VIEWS, +} from '@/components/interfaces/Storage/Storage.constants' +import { FileExplorerRow } from '@/components/interfaces/Storage/StorageExplorer/FileExplorerRow' +import { customRender as render } from '@/tests/lib/custom-render' + +vi.mock('@/state/storage-explorer', () => ({ + useStorageExplorerStateSnapshot: () => ({ + projectRef: 'abcdef', + selectedBucket: { id: 'my-bucket', name: 'my-bucket', public: false }, + selectedFilePreview: undefined, + openedFolders: [], + setSelectedFileCustomExpiry: vi.fn(), + setSelectedItems: vi.fn(), + setSelectedItemsToDelete: vi.fn(), + downloadFile: vi.fn(), + setSelectedItemToRename: vi.fn(), + setSelectedItemsToMove: vi.fn(), + downloadFolder: vi.fn(), + selectRangeItems: vi.fn(), + }), +})) +vi.mock('@/components/interfaces/Storage/StorageExplorer/StorageExplorerNavigation', () => ({ + useStorageExplorerNavigation: () => ({ + openFolderAtIndex: vi.fn(), + truncateToColumn: vi.fn(), + setPreviewedFile: vi.fn(), + clearPreviewedFile: vi.fn(), + }), +})) +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: () => ({ can: true }), +})) +vi.mock('@/components/interfaces/Storage/StorageExplorer/useCopyUrl', () => ({ + useCopyUrl: () => ({ onCopyUrl: vi.fn() }), +})) + +const base = { + status: STORAGE_ROW_STATUS.READY, + metadata: { size: 10, mimetype: 'image/png' }, + isCorrupted: false, + created_at: null, + updated_at: null, + last_accessed_at: null, + path: 'photo.png', +} + +describe('FileExplorerRow', () => { + it('offers both the relative path and the dashboard URL for a file', async () => { + render( + + ) + await userEvent.click(screen.getByRole('button', { name: 'photo.png actions' })) + expect(await screen.findByText('Copy relative path')).toBeInTheDocument() + expect(screen.getByText('Copy link')).toBeInTheDocument() + }) + + it('offers both the relative path and the dashboard URL for a folder', async () => { + render( + + ) + await userEvent.click(screen.getByRole('button', { name: 'avatars actions' })) + expect(await screen.findByText('Copy relative path')).toBeInTheDocument() + expect(screen.getByText('Copy link')).toBeInTheDocument() + expect(screen.queryByText('Copy path to folder')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index dbd9a5711ca..5865a4c8dd7 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -5,12 +5,13 @@ import { Copy, Download, Edit, + Link2, LoaderCircle, MoreVertical, Move, Trash2, } from 'lucide-react' -import type { CSSProperties } from 'react' +import type { CSSProperties, ReactNode } from 'react' import { Checkbox, cn, @@ -38,7 +39,7 @@ import { StorageItemWithColumn, type StorageItem } from '../Storage.types' import { StorageRowIcon } from '../StorageRowIcon' import { useFileExplorerContextMenu } from './FileExplorerRowContextMenu' import { FileExplorerRowEditing } from './FileExplorerRowEditing' -import { copyPathToFolder } from './StorageExplorer.utils' +import { copyStorageExplorerUrl, copyStoragePath } from './StorageExplorer.utils' import { useStorageExplorerNavigation } from './StorageExplorerNavigation' import { useCopyUrl } from './useCopyUrl' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -54,6 +55,13 @@ interface FileExplorerRowProps { style?: CSSProperties } +type RowOption = { + name: string + icon?: ReactNode + onClick?: () => void + children?: { name: string; onClick: () => void }[] +} + export const FileExplorerRow = ({ index: itemIndex, item, @@ -63,6 +71,7 @@ export const FileExplorerRow = ({ style, }: FileExplorerRowProps) => { const { + projectRef, selectedBucket, selectedFilePreview, openedFolders, @@ -103,7 +112,26 @@ export const FileExplorerRow = ({ clearPreviewedFile() } - const rowOptions = + const copyPathOptions: RowOption[] = [ + { + name: 'Copy relative path', + icon: , + onClick: () => copyStoragePath(openedFolders, itemWithColumnIndex), + }, + { + name: 'Copy link', + icon: , + onClick: () => + copyStorageExplorerUrl({ + openedFolders, + item: itemWithColumnIndex, + projectRef, + bucketId: selectedBucket.id, + }), + }, + ] + + const rowOptions: RowOption[] = item.type === STORAGE_ROW_TYPES.FOLDER ? [ ...(canUpdateFiles @@ -120,11 +148,7 @@ export const FileExplorerRow = ({ icon: , onClick: () => downloadFolder(itemWithColumnIndex), }, - { - name: 'Copy path to folder', - icon: , - onClick: () => copyPathToFolder(openedFolders, itemWithColumnIndex), - }, + ...copyPathOptions, ...(canUpdateFiles ? [ { name: 'Separator', icon: undefined, onClick: undefined }, @@ -181,6 +205,7 @@ export const FileExplorerRow = ({ icon: , onClick: () => downloadFile(itemWithColumnIndex), }, + ...copyPathOptions, ...(canUpdateFiles ? [ { diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts index 6d4569da354..71876d4dfb2 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts @@ -1,4 +1,5 @@ import { toast } from 'sonner' +import { copyToClipboard } from 'ui' import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -7,8 +8,12 @@ import { } from '@/components/interfaces/Storage/Storage.constants' import type { StorageItem } from '@/components/interfaces/Storage/Storage.types' import { + copyStorageExplorerUrl, + copyStoragePath, getPathAlongFoldersToIndex, getPathAlongOpenedFolders, + getStorageExplorerUrlForItem, + getStoragePathForItem, parseStoragePath, sanitizeNameForDuplicateInColumn, serializeStoragePath, @@ -159,7 +164,8 @@ describe('getPathAlongFoldersToIndex', () => { }) }) -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('ui', () => ({ copyToClipboard: vi.fn() })) describe('sanitizeNameForDuplicateInColumn', () => { // Reset mock call counts between tests @@ -318,3 +324,131 @@ describe('serializeStoragePath', () => { expect(parseStoragePath(serializeStoragePath(segments))).toEqual(segments) }) }) + +function makeFile(name: string): StorageItem { + return { ...makeFolder(name), id: name, type: STORAGE_ROW_TYPES.FILE } +} + +describe('getStoragePathForItem', () => { + it('returns just the name at the bucket root', () => { + expect(getStoragePathForItem([], { ...makeFile('photo.png'), columnIndex: 0 })).toBe( + 'photo.png' + ) + }) + + it('joins the opened folder chain above the item', () => { + const openedFolders = [makeFolder('avatars'), makeFolder('2024')] + expect(getStoragePathForItem(openedFolders, { ...makeFile('photo.png'), columnIndex: 2 })).toBe( + 'avatars/2024/photo.png' + ) + }) + + it('uses the same shape for folders', () => { + const openedFolders = [makeFolder('avatars')] + expect(getStoragePathForItem(openedFolders, { ...makeFolder('2024'), columnIndex: 1 })).toBe( + 'avatars/2024' + ) + }) + + it('omits the bucket name so the value works with storage.from(bucket)', () => { + const openedFolders = [makeFolder('avatars')] + const path = getStoragePathForItem(openedFolders, { ...makeFile('a.png'), columnIndex: 1 }) + expect(path.startsWith('my-bucket')).toBe(false) + }) +}) + +describe('getStorageExplorerUrlForItem', () => { + const projectRef = 'abcdef' + const bucketId = 'my-bucket' + + it('points a folder link at the folder itself', () => { + const url = new URL( + getStorageExplorerUrlForItem({ + openedFolders: [makeFolder('avatars')], + item: { ...makeFolder('2024'), columnIndex: 1 }, + projectRef, + bucketId, + }) + ) + + expect(url.pathname).toContain(`/project/${projectRef}/storage/files/buckets/${bucketId}`) + expect(url.searchParams.get('path')).toBe('avatars/2024') + expect(url.searchParams.get('preview')).toBeNull() + }) + + it('points a file link at its parent folder plus the file', () => { + const url = new URL( + getStorageExplorerUrlForItem({ + openedFolders: [makeFolder('avatars'), makeFolder('2024')], + item: { ...makeFile('photo.png'), columnIndex: 2 }, + projectRef, + bucketId, + }) + ) + + expect(url.searchParams.get('path')).toBe('avatars/2024') + expect(url.searchParams.get('preview')).toBe('photo.png') + }) + + it('omits path at the bucket root', () => { + const url = new URL( + getStorageExplorerUrlForItem({ + openedFolders: [], + item: { ...makeFile('photo.png'), columnIndex: 0 }, + projectRef, + bucketId, + }) + ) + + expect(url.searchParams.get('path')).toBeNull() + expect(url.searchParams.get('preview')).toBe('photo.png') + }) + + it('escapes a bucket id that needs encoding', () => { + const url = new URL( + getStorageExplorerUrlForItem({ + openedFolders: [], + item: { ...makeFolder('a'), columnIndex: 0 }, + projectRef, + bucketId: 'a b/c', + }) + ) + + expect(url.pathname).toContain('a%20b%2Fc') + }) +}) + +describe('clipboard helpers', () => { + beforeEach(() => { + vi.mocked(copyToClipboard).mockClear() + vi.mocked(toast.success).mockClear() + }) + + it('announces a copied relative path only once the write has landed', () => { + copyStoragePath([makeFolder('images')], { ...makeFile('photo.png'), columnIndex: 1 }) + + const [text, onCopied] = vi.mocked(copyToClipboard).mock.calls[0] + expect(text).toBe('images/photo.png') + // The write is async and reports its own failure, so nothing is claimed up front + expect(toast.success).not.toHaveBeenCalled() + + onCopied?.() + expect(toast.success).toHaveBeenCalledWith('Copied relative path for "photo.png"') + }) + + it('announces a copied URL only once the write has landed', () => { + copyStorageExplorerUrl({ + openedFolders: [], + item: { ...makeFile('photo.png'), columnIndex: 0 }, + projectRef: 'abcdefghijklmnopqrst', + bucketId: 'my-bucket', + }) + + const [text, onCopied] = vi.mocked(copyToClipboard).mock.calls[0] + expect(text).toContain('preview=photo.png') + expect(toast.success).not.toHaveBeenCalled() + + onCopied?.() + expect(toast.success).toHaveBeenCalledWith('Copied URL for "photo.png"') + }) +}) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx index 3ea6ed6cbfd..98991c1125b 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx @@ -5,6 +5,7 @@ import { inverseValidObjectKeyRegex, validObjectKeyRegex } from '../CreateBucket import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants' import { StorageItem, StorageItemMetadata } from '../Storage.types' import type { StorageObject } from '@/data/storage/bucket-objects-list-mutation' +import { BASE_PATH } from '@/lib/constants' import type { StorageExplorerState } from '@/state/storage-explorer' type UploadProgress = { @@ -130,14 +131,60 @@ export function sanitizeNameForDuplicateInColumn( return name } -export const copyPathToFolder = ( - openedFolders: StorageItem[], +/** Bucket-relative path to an item */ +export function getStoragePathForItem( + openedFolders: readonly StorageItem[], + item: StorageItem & { columnIndex: number } +): string { + const folders = openedFolders.slice(0, item.columnIndex).map((folder) => folder.name) + return folders.length > 0 ? `${folders.join('/')}/${item.name}` : item.name +} + +/** Absolute dashboard URL that reopens the item in the storage explorer */ +export function getStorageExplorerUrlForItem({ + openedFolders, + item, + projectRef, + bucketId, +}: { + openedFolders: readonly StorageItem[] + item: StorageItem & { columnIndex: number } + projectRef: string + bucketId: string +}): string { + const folders = openedFolders.slice(0, item.columnIndex).map((folder) => folder.name) + const isFolder = item.type === STORAGE_ROW_TYPES.FOLDER + + const url = new URL( + `${BASE_PATH}/project/${projectRef}/storage/files/buckets/${encodeURIComponent(bucketId)}`, + location.origin + ) + const path = serializeStoragePath(isFolder ? [...folders, item.name] : folders) + if (path.length > 0) url.searchParams.set('path', path) + if (!isFolder) url.searchParams.set('preview', item.name) + + return url.toString() +} + +export const copyStoragePath = ( + openedFolders: readonly StorageItem[], item: StorageItem & { columnIndex: number } ) => { - const folders = openedFolders.slice(0, item.columnIndex).map((folder) => folder.name) - const path = folders.length > 0 ? `${folders.join('/')}/${item.name}` : item.name - copyToClipboard(path) - toast.success(`Copied path to folder "${item.name}"`) + // Toast from the callback: the write is async and reports its own failures. + copyToClipboard(getStoragePathForItem(openedFolders, item), () => + toast.success(`Copied relative path for "${item.name}"`) + ) +} + +export const copyStorageExplorerUrl = (params: { + openedFolders: readonly StorageItem[] + item: StorageItem & { columnIndex: number } + projectRef: string + bucketId: string +}) => { + copyToClipboard(getStorageExplorerUrlForItem(params), () => + toast.success(`Copied URL for "${params.item.name}"`) + ) } export const formatTime = (seconds: number) => { From ef527f447a4c23447138401237b7f35acf6369a7 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:46:09 +0200 Subject: [PATCH 10/31] chore(studio): enable Sentry build diagnostics (#50474) ## Problem Studio Vercel builds can stall after compilation inside the Sentry production compile hook. Sentry currently suppresses its build output, so the deployment log does not show which operation stalls. ## Change Enable Sentry build diagnostics for Studio platform builds on Vercel by setting silent to false and debug to true. Source-map generation, upload behavior, and runtime reporting remain unchanged. ## How to test - Deploy this branch to the Studio Vercel project. - Inspect the log after Next.js compilation completes. - Confirm that Sentry reports its post-compile progress and exposes the operation that stalls or fails. The diagnostic build may still time out; this change is intended to reveal the cause before applying a workaround. ## Summary by CodeRabbit - **Chores** - Enabled additional diagnostic logging for platform builds to improve visibility into build-time error monitoring configuration. --- apps/studio/next.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index 35a9d579c9f..929cde8b773 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -219,7 +219,8 @@ const platformConfig = export default process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && process.env.VERCEL === '1' ? withSentryConfig(platformConfig, { - silent: true, + silent: false, + debug: true, // For all available options, see: // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ From 4bb36b944fe3bb5cc4825e9653e694c883f0f21b Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:21:50 -0400 Subject: [PATCH 11/31] feat(studio): let High Compliance projects opt-in to Assistant data access (#50548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orgs with the HIPAA add-on had the Assistant's opt-in level forced to `disabled` on any project marked High Compliance, regardless of what the org picked in its AI settings. The restriction predated our AI provider BAAs. The consequence is those users see the Assistant failing to answer questions about their data w/ no clear path how to fix it, even though the LLM provider supports this use case. This PR removes these Assistant restrictions on the server and client so those projects honor the org's chosen level. Braintrust conversation tracing is unchanged and still blocked for these projects, see [this test case](https://github.com/supabase/supabase/blob/b9800ccf16/apps/studio/lib/ai/braintrust-logger.test.ts#L16-L20). See [comments](https://linear.app/supabase/issue/AI-1153/allow-hipaa-orgs-to-opt-in-to-assistant-data-access-for-high#comment-485a0d46) for legal approval and conditions. The client-side changes enable features like "Debug with AI" on SQL query failures, “Generate/Rename with AI” for snippet titles, and generated Assistant chat titles for these customers. The AI opt-in copy now adds a reminder to obtain consent from data subjects, linking the [shared responsibility model](https://supabase.com/docs/guides/deployment/shared-responsibility-model) based also on [this comment](https://linear.app/supabase/issue/AI-1153/allow-hipaa-orgs-to-opt-in-to-assistant-data-access-for-high#comment-f81ee610). CleanShot 2026-09-17 at 5 01 02 PM@2x To test with a HIPAA-enabled project in staging, you can use this [Plan Change [Staging]](https://app.hex.tech/supabase/app/Plan-Change-Staging-032BD32jo1EaisCS85qunf/latest) Hex to add the HIPAA add-on. Once the add-on is present, you can turn on High Compliance from a project's settings. Also in org settings, crank up the Assistant data opt-in level and verify the Assistant is able to answer questions about the project's data. My results testing with opt-in level "Schema, Logs & Database Data": | High compliance setting | Data opt-in working | |--------|--------| | CleanShot 2026-09-17 at 5 03 36
PM@2x | CleanShot 2026-09-17 at 5 05
14 PM@2x | Closes AI-1153 ## Summary by CodeRabbit ## New Features - AI-assisted query renaming, snippet title generation, debugging, and tools now follow organization AI opt-in settings rather than project HIPAA status. - Debugging assistance and AI actions remain available for eligible users without additional HIPAA-based blocking. - AI metadata warnings consistently show standard opt-in messaging and permission settings. - AI settings remind users to obtain consent before entering personal data and link to shared responsibility guidance. --------- Co-authored-by: Joshen Lim --- .../Explorer/ExplorerChatToolbar.tsx | 2 - .../QueryResultError.selfhosted.test.tsx | 81 ------------------- .../QueryEditor/QueryResultError.test.tsx | 49 ----------- .../Explorer/QueryEditor/QueryResultError.tsx | 29 +------ .../GeneralSettings/AIOptInLevelSelector.tsx | 9 ++- .../GeneralSettings/OptInToOpenAIToggle.tsx | 8 +- .../interfaces/SQLEditor/RenameQueryModal.tsx | 19 ++--- .../interfaces/SQLEditor/SQLEditor.utils.ts | 7 +- .../UtilityPanel/UtilityTabResults.tsx | 28 ++----- .../ProjectComplianceMode.tsx | 2 +- .../AIAssistantHeader.test.tsx | 1 - .../ui/AIAssistantPanel/AIAssistantHeader.tsx | 3 - .../AIAssistantMetadataWarning.tsx | 30 +++---- .../ui/AIAssistantPanel/AssistantChat.tsx | 4 +- .../ui/EditorPanel/SaveSnippetDialog.tsx | 17 ++-- apps/studio/hooks/misc/useOrgOptedIntoAi.ts | 26 +----- apps/studio/lib/ai/ai-details.test.ts | 54 +++---------- apps/studio/lib/ai/ai-details.ts | 9 +-- apps/studio/lib/ai/tool-filter.test.ts | 32 ++------ apps/studio/lib/ai/tool-filter.ts | 23 ++---- apps/studio/lib/ai/tools/index.test.ts | 2 - apps/studio/lib/ai/tools/index.ts | 10 +-- apps/studio/lib/ai/tools/mcp-tools.test.ts | 1 - apps/studio/lib/ai/tools/mcp-tools.ts | 8 +- apps/studio/pages/api/ai/sql/generate-v4.ts | 3 - apps/studio/pages/api/ai/sql/policy.ts | 3 - 26 files changed, 86 insertions(+), 374 deletions(-) delete mode 100644 apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.selfhosted.test.tsx diff --git a/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx b/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx index 0c2511aa07f..07748bfc964 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx @@ -36,7 +36,6 @@ export const ExplorerChatToolbar = ({ isChatLoading, showMetadataWarning, updatedOptInSinceMCP, - isHipaaProjectDisallowed, aiOptInLevel, }: ExplorerChatToolbarProps) => { const snap = useAiAssistantStateSnapshot() @@ -120,7 +119,6 @@ export const ExplorerChatToolbar = ({ onVisibleChange={setIsOptInModalOpen} showMetadataWarning={showMetadataWarning} updatedOptInSinceMCP={updatedOptInSinceMCP} - isHipaaProjectDisallowed={isHipaaProjectDisallowed} aiOptInLevel={aiOptInLevel} /> diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.selfhosted.test.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.selfhosted.test.tsx deleted file mode 100644 index 354291b5ed0..00000000000 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.selfhosted.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { QueryResultError } from './QueryResultError' -import { customRender } from '@/tests/lib/custom-render' -import { addAPIMock } from '@/tests/lib/msw' - -const mocks = vi.hoisted(() => ({ - createChat: vi.fn(), - useParams: vi.fn(), -})) - -vi.mock('@/lib/constants', async () => { - const actual = await vi.importActual>('@/lib/constants') - return { ...actual, IS_PLATFORM: false } -}) - -vi.mock('common', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useParams: () => mocks.useParams() } -}) - -vi.mock('../hooks', () => ({ - useCreateChat: () => ({ createChat: mocks.createChat, isCreating: false }), -})) - -vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ - useSelectedOrganizationQuery: () => ({ data: undefined }), -})) - -// Self-hosted has no orgs/billing, so these eligibility queries are expected to never -// resolve (disabled or failing) - the dropdown must not stay hidden waiting on them. -vi.mock('@/data/subscriptions/org-subscription-query', () => ({ - useOrgSubscriptionQuery: () => ({ data: undefined, isSuccess: false }), -})) - -vi.mock('@/data/config/project-settings-v2-query', () => ({ - useProjectSettingsV2Query: () => ({ data: undefined, isSuccess: false }), -})) - -describe('QueryResultError (self-hosted)', () => { - beforeEach(() => { - mocks.useParams.mockReturnValue({ ref: 'default' }) - addAPIMock({ - method: 'get', - path: '/platform/projects/:ref', - response: { - id: 1, - ref: 'default', - organization_id: 1, - name: 'Test Project', - status: 'ACTIVE_HEALTHY', - cloud_provider: 'AWS', - region: 'us-east-1', - db_host: 'db.default.supabase.co', - restUrl: 'https://default.supabase.co/rest/v1/', - inserted_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - subscription_id: 'sub_123', - is_branch_enabled: false, - is_physical_backups_enabled: false, - high_availability: false, - integration_source: null, - connectionString: 'postgresql://postgres@localhost:5432/postgres', - is_hibernating: false, - }, - }) - }) - - it('renders the assistant dropdown without waiting on HIPAA eligibility queries', () => { - customRender( - - ) - - expect(screen.getByRole('button', { name: 'Debug with Assistant' })).toBeInTheDocument() - }) -}) diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.test.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.test.tsx index 7b7f1732bb1..3a1bbe8b0f8 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.test.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.test.tsx @@ -10,8 +10,6 @@ const mocks = vi.hoisted(() => ({ createChat: vi.fn(), useParams: vi.fn(), mockCopyToClipboard: vi.fn(), - useOrgSubscriptionQuery: vi.fn(), - useProjectSettingsV2Query: vi.fn(), })) vi.mock('common', async (importOriginal) => { @@ -19,13 +17,6 @@ vi.mock('common', async (importOriginal) => { return { ...actual, useParams: () => mocks.useParams() } }) -// This file covers the platform-mode HIPAA eligibility gate; the self-hosted bypass is -// covered separately in QueryResultError.selfhosted.test.tsx. -vi.mock('@/lib/constants', async () => { - const actual = await vi.importActual>('@/lib/constants') - return { ...actual, IS_PLATFORM: true } -}) - // CopyButton and AiAssistantDropdown write via copyToClipboard from 'ui'. Stub just that // export so we can assert the value handed to the clipboard without depending on jsdom's // document.hasFocus() / navigator.clipboard. Everything else in 'ui' stays real. @@ -42,19 +33,9 @@ vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ useSelectedOrganizationQuery: () => ({ data: { slug: 'default-org' } }), })) -vi.mock('@/data/subscriptions/org-subscription-query', () => ({ - useOrgSubscriptionQuery: () => mocks.useOrgSubscriptionQuery(), -})) - -vi.mock('@/data/config/project-settings-v2-query', () => ({ - useProjectSettingsV2Query: () => mocks.useProjectSettingsV2Query(), -})) - describe('QueryResultError', () => { beforeEach(() => { mocks.useParams.mockReturnValue({ ref: 'default' }) - mocks.useOrgSubscriptionQuery.mockReturnValue({ data: undefined, isSuccess: true }) - mocks.useProjectSettingsV2Query.mockReturnValue({ data: undefined, isSuccess: true }) // useTrack() (invoked by AiAssistantDropdown) reads the selected project to attach // telemetry context, so the platform project fetch needs a handler even though this // component doesn't read project data itself. @@ -145,34 +126,4 @@ describe('QueryResultError', () => { expect(screen.queryByRole('button', { name: 'Debug with Assistant' })).not.toBeInTheDocument() }) - - it('does not render the assistant dropdown while HIPAA eligibility is still resolving', () => { - mocks.useOrgSubscriptionQuery.mockReturnValue({ data: undefined, isSuccess: false }) - - customRender( - - ) - - expect(screen.queryByRole('button', { name: 'Debug with Assistant' })).not.toBeInTheDocument() - }) - - it('does not render the assistant dropdown when an eligibility query is disabled or failed', () => { - // A disabled or failed query also settles with isSuccess: false forever - same as - // still-loading from this component's point of view, so it stays denied. - mocks.useProjectSettingsV2Query.mockReturnValue({ data: undefined, isSuccess: false }) - - customRender( - - ) - - expect(screen.queryByRole('button', { name: 'Debug with Assistant' })).not.toBeInTheDocument() - }) }) diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx index 0ce4fc3af25..be46a183770 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx @@ -1,10 +1,8 @@ -import { useParams } from 'common' import { ExternalLink } from 'lucide-react' import { parseAsBoolean, useQueryState } from 'nuqs' import { useCallback } from 'react' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { subscriptionHasHipaaAddon } from '../../Billing/Subscription/Subscription.utils' import { type SqlSnippetSource } from '../../SQLEditor/querySource' import { buildDebugPromptText } from '../../SQLEditor/SQLEditor.utils' import { useCreateChat } from '../hooks' @@ -12,11 +10,8 @@ import { type QueryResult } from '../types' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import CopyButton from '@/components/ui/CopyButton' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' -import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { getSqlErrorLines } from '@/data/sql/utils' -import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' -import { DOCS_URL, IS_PLATFORM } from '@/lib/constants' +import { DOCS_URL } from '@/lib/constants' export const QueryResultError = ({ error, @@ -34,26 +29,6 @@ export const QueryResultError = ({ * into that conversation's composer instead of abandoning it for a new chat. */ onDebug?: (prompt: string) => void }) => { - const { ref } = useParams() - - const { data: org } = useSelectedOrganizationQuery() - const { data: subscription, isSuccess: isSubscriptionResolved } = useOrgSubscriptionQuery({ - orgSlug: org?.slug, - }) - const { data: projectSettings, isSuccess: isProjectSettingsResolved } = useProjectSettingsV2Query( - { - projectRef: ref, - } - ) - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) && projectSettings?.is_sensitive - // Default deny until both eligibility queries have actually succeeded - a disabled or - // failed query also reports isLoading: false, so isLoading can't tell "confirmed no - // addon" apart from "don't know yet", and the assistant sends the SQL and error to an - // LLM. Self-hosted has no HIPAA concept at all (subscriptionHasHipaaAddon short-circuits - // to false there), so there's nothing to wait on outside of platform. - const isCheckingHipaaEligibility = - IS_PLATFORM && (!isSubscriptionResolved || !isProjectSettingsResolved) - const { createChat, isCreating } = useCreateChat() const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false)) @@ -175,7 +150,7 @@ export const QueryResultError = ({ )} - {!hasHipaaAddon && !isCheckingHipaaEligibility && canDebug && ( + {canDebug && ( @@ -89,7 +91,12 @@ export const AIOptInLevelSelector = ({

For organizations with HIPAA compliance enabled in their Supabase configuration, any consented information will only be shared with third-party AI providers with whom - Supabase has established a Business Associate Agreement (BAA). + Supabase has established a Business Associate Agreement (BAA). Don't input personal data + unless you've{' '} + + obtained consent + {' '} + from the individuals it relates to.

diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx index 44272d1bcf5..52c31dd73d0 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx @@ -9,6 +9,7 @@ import { } from 'ui' import { InlineLink } from '@/components/ui/InlineLink' +import { DOCS_URL } from '@/lib/constants' export const OptInToOpenAIToggle = () => { return ( @@ -42,7 +43,12 @@ export const OptInToOpenAIToggle = () => {

For organizations with HIPAA compliance enabled in their Supabase configuration, any consented information will only be shared with third-party AI providers with whom - Supabase has established a Business Associate Agreement (BAA). + Supabase has established a Business Associate Agreement (BAA). Don't input personal data + unless you've{' '} + + obtained consent + {' '} + from the individuals it relates to.

diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index 98d9f54786d..4a0e55a4230 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -63,8 +63,7 @@ const RenameQueryForm = ({ snippet, onCancel, onComplete }: RenameQueryFormProps const tabsSnap = useTabsStateSnapshot() const isSQLSnippet = snippet.type === 'sql' - // Orgs on HIPAA plans or that have disabled AI should not have access to Supabase AI - const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel() + const { aiOptInLevel } = useOrgAiOptInLevel() const isAiOptedOut = aiOptInLevel === 'disabled' const { id, name, description } = snippet @@ -180,19 +179,15 @@ const RenameQueryForm = ({ snippet, onCancel, onComplete }: RenameQueryFormProps generateTitle()} size="tiny" - disabled={ - isTitleGenerationLoading || !isApiKeySet || isHipaaProjectDisallowed || isAiOptedOut - } + disabled={isTitleGenerationLoading || !isApiKeySet || isAiOptedOut} tooltip={{ content: { side: 'bottom', - text: isHipaaProjectDisallowed - ? 'This feature is not available for HIPAA projects.' - : isAiOptedOut - ? 'Your organization has opted out of AI features.' - : isApiKeySet - ? undefined - : 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.', + text: isAiOptedOut + ? 'Your organization has opted out of AI features.' + : isApiKeySet + ? undefined + : 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.', }, }} > diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index 406ac4419ca..b8bcd369c9c 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -263,10 +263,9 @@ export function resolveConnectionString( /** * Whether a query run should lazily kick off AI title generation for the - * snippet: only when the org has AI enabled (not disabled/HIPAA — which would - * silently forward the query to the AI provider without consent), the - * snippet still has its placeholder name, and we're running on the hosted - * platform. + * snippet: only when the org has AI enabled (a disabled org would silently + * forward the query to the AI provider without consent), the snippet still + * has its placeholder name, and we're running on the hosted platform. */ export function shouldAutoGenerateTitle({ aiOptInLevel, diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx index 077c86eaa5d..1f24875a615 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx @@ -4,15 +4,11 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { forwardRef } from 'react' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import CopyButton from '@/components/ui/CopyButton' import { DataGridResults } from '@/components/ui/DataGridResults' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' -import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { getSqlErrorLines } from '@/data/sql/utils' -import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL } from '@/lib/constants' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' @@ -30,16 +26,10 @@ export const UtilityTabResults = forwardRef { const { ref } = useParams() const state = useDatabaseSelectorStateSnapshot() - const { data: organization } = useSelectedOrganizationQuery() const sessionSnap = useSqlEditorSessionSnapshot() const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false)) const result = sessionSnap.results[id]?.[0] - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) - - // Customers on HIPAA plans should not have access to Supabase AI - const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: ref }) - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) && projectSettings?.is_sensitive const isTimeout = result?.error?.message?.includes('canceling statement due to statement timeout') || @@ -154,16 +144,14 @@ export const UtilityTabResults = forwardRef )} - {!hasHipaaAddon && ( - - )} +

diff --git a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx index d4ee5778de4..a5213b1b05a 100644 --- a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx +++ b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx @@ -68,7 +68,7 @@ export const ComplianceConfig = () => { return ( -
+
High Compliance Configuration diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.test.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.test.tsx index be9b541589b..7de470967cb 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.test.tsx @@ -50,7 +50,6 @@ const defaultProps = { onCloseAssistant: vi.fn(), showMetadataWarning: false, updatedOptInSinceMCP: true, - isHipaaProjectDisallowed: false, aiOptInLevel: 'full', } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx index 91b3873c2bc..47b06e4f606 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantHeader.tsx @@ -39,7 +39,6 @@ interface AIAssistantHeaderProps { onCloseAssistant: () => void showMetadataWarning: boolean updatedOptInSinceMCP: boolean - isHipaaProjectDisallowed: boolean aiOptInLevel: 'disabled' | 'schema' | 'full' | string | undefined } @@ -50,7 +49,6 @@ export const AIAssistantHeader = ({ onCloseAssistant, showMetadataWarning, updatedOptInSinceMCP, - isHipaaProjectDisallowed, aiOptInLevel, }: AIAssistantHeaderProps) => { const { openChat } = useCreateChat() @@ -244,7 +242,6 @@ export const AIAssistantHeader = ({ onVisibleChange={setIsOptInModalOpen} showMetadataWarning={showMetadataWarning} updatedOptInSinceMCP={updatedOptInSinceMCP} - isHipaaProjectDisallowed={isHipaaProjectDisallowed} aiOptInLevel={aiOptInLevel} />
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantMetadataWarning.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantMetadataWarning.tsx index fba455d0ced..abf8a313909 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantMetadataWarning.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantMetadataWarning.tsx @@ -8,7 +8,6 @@ interface AIAssistantMetadataWarningProps { onVisibleChange: (visible: boolean) => void showMetadataWarning: boolean updatedOptInSinceMCP: boolean - isHipaaProjectDisallowed: boolean aiOptInLevel: 'disabled' | 'schema' | 'full' | string | undefined } @@ -17,7 +16,6 @@ export const AIAssistantMetadataWarning = ({ onVisibleChange, showMetadataWarning, updatedOptInSinceMCP, - isHipaaProjectDisallowed, aiOptInLevel, }: AIAssistantMetadataWarningProps) => ( <> @@ -27,30 +25,24 @@ export const AIAssistantMetadataWarning = ({ title={ !updatedOptInSinceMCP ? 'The Assistant has just been updated to help you better!' - : isHipaaProjectDisallowed - ? 'Project metadata is not shared due to HIPAA' - : aiOptInLevel === 'disabled' - ? 'Project metadata is currently not shared' - : 'Limited metadata is shared to the Assistant' + : aiOptInLevel === 'disabled' + ? 'Project metadata is currently not shared' + : 'Limited metadata is shared to the Assistant' } description={ !updatedOptInSinceMCP ? 'You may now opt-in to share schema metadata and even logs for better results' - : isHipaaProjectDisallowed - ? 'Your organization has the HIPAA addon and will not send project metadata with your prompts for projects marked as HIPAA.' - : aiOptInLevel === 'disabled' - ? 'The Assistant can provide better answers if you opt-in to share schema metadata.' - : aiOptInLevel === 'schema' - ? 'Sharing query data in addition to schema can further improve responses. Update AI settings to enable this.' - : '' + : aiOptInLevel === 'disabled' + ? 'The Assistant can provide better answers if you opt-in to share schema metadata.' + : aiOptInLevel === 'schema' + ? 'Sharing query data in addition to schema can further improve responses. Update AI settings to enable this.' + : '' } className="border-0 border-b rounded-none bg-background" > - {!isHipaaProjectDisallowed && ( - - )} + )} onVisibleChange(false)} /> diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx index 636122430ac..24c7add23cc 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx @@ -51,7 +51,6 @@ export interface AssistantChatHeaderProps { isChatLoading: boolean showMetadataWarning: boolean updatedOptInSinceMCP: boolean - isHipaaProjectDisallowed: boolean aiOptInLevel: 'disabled' | 'schema' | 'full' | string | undefined } @@ -110,7 +109,7 @@ export const AssistantChat = ({ const inputRef = useRef(null) - const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel() + const { aiOptInLevel } = useOrgAiOptInLevel() // Whether attached queries are sent at all. One definition, shared by the chat form // (which folds them into the message text) and the message metadata (which states // whether any of them was a logs query), so the two can't disagree. @@ -559,7 +558,6 @@ export const AssistantChat = ({ isChatLoading, showMetadataWarning, updatedOptInSinceMCP, - isHipaaProjectDisallowed, aiOptInLevel, })} {hasMessages ? ( diff --git a/apps/studio/components/ui/EditorPanel/SaveSnippetDialog.tsx b/apps/studio/components/ui/EditorPanel/SaveSnippetDialog.tsx index ffc8df9e106..f669642324c 100644 --- a/apps/studio/components/ui/EditorPanel/SaveSnippetDialog.tsx +++ b/apps/studio/components/ui/EditorPanel/SaveSnippetDialog.tsx @@ -34,8 +34,7 @@ export const SaveSnippetDialog = ({ open, sql, onOpenChange, onSave }: SaveSnipp const isApiKeySet = !!check?.hasKey - // Orgs on HIPAA plans or that have disabled AI should not have access to Supabase AI - const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel() + const { aiOptInLevel } = useOrgAiOptInLevel() const isAiOptedOut = aiOptInLevel === 'disabled' const { mutate: generateTitle, isPending: isGenerating } = useSqlTitleGenerateMutation({ @@ -78,18 +77,16 @@ export const SaveSnippetDialog = ({ open, sql, onOpenChange, onSave }: SaveSnipp
generateTitle({ sql })} tooltip={{ content: { side: 'bottom', - text: isHipaaProjectDisallowed - ? 'This feature is not available for HIPAA projects.' - : isAiOptedOut - ? 'Your organization has opted out of AI features.' - : isApiKeySet - ? undefined - : 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.', + text: isAiOptedOut + ? 'Your organization has opted out of AI features.' + : isApiKeySet + ? undefined + : 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.', }, }} > diff --git a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts index 00ba0a91862..d23dd36a18f 100644 --- a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts +++ b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts @@ -1,10 +1,6 @@ import { z } from 'zod' -import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils' -import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' -import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM, OPT_IN_TAGS } from '@/lib/constants' export const aiOptInLevelSchema = z.enum([ @@ -49,9 +45,7 @@ export function useOrgOptedIntoAi(): boolean { export function useOrgAiOptInLevel(): { aiOptInLevel: AiOptInLevel includeSchemaMetadata: boolean - isHipaaProjectDisallowed: boolean } { - const { data: selectedProject } = useSelectedProjectQuery() const { data: selectedOrganization } = useSelectedOrganizationQuery() // [Joshen] Default to disabled until migration to clean up existing opt in tags are completed @@ -60,25 +54,9 @@ export function useOrgAiOptInLevel(): { const level = getAiOptInLevel(optInTags) const isOptedIntoAI = level !== 'disabled' - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - - const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: selectedProject?.ref }) - const isProjectSensitive = !!projectSettings?.is_sensitive - - const preventProjectFromUsingAI = hasHipaaAddon && isProjectSensitive - // [Joshen] For CLI / self-host, we'd default to 'schema' as opt in level - const aiOptInLevel = !IS_PLATFORM - ? 'schema' - : (isOptedIntoAI && !selectedProject) || (isOptedIntoAI && !preventProjectFromUsingAI) - ? level - : 'disabled' + const aiOptInLevel = !IS_PLATFORM ? 'schema' : isOptedIntoAI ? level : 'disabled' const includeSchemaMetadata = !IS_PLATFORM || aiOptInLevel !== 'disabled' - return { - aiOptInLevel, - includeSchemaMetadata, - isHipaaProjectDisallowed: preventProjectFromUsingAI, - } + return { aiOptInLevel, includeSchemaMetadata } } diff --git a/apps/studio/lib/ai/ai-details.test.ts b/apps/studio/lib/ai/ai-details.test.ts index 2e8375cc394..5589621f098 100644 --- a/apps/studio/lib/ai/ai-details.test.ts +++ b/apps/studio/lib/ai/ai-details.test.ts @@ -93,7 +93,6 @@ describe('getAIDetails', () => { planId: 'pro', region: 'us-east-1', isSensitive: false, - isRestrictedByHipaa: false, }) }) @@ -233,49 +232,20 @@ describe('getAIDetails', () => { expect(result.region).toBeUndefined() }) - describe('HIPAA organizations', () => { - beforeEach(() => { - mockSubscriptionHasHipaaAddon.mockReturnValue(true) - mockGetAiOptInLevel.mockReturnValue('schema_and_log_and_data') + it('keeps the opt-in level for a sensitive project in a HIPAA org', async () => { + mockSubscriptionHasHipaaAddon.mockReturnValue(true) + mockGetAiOptInLevel.mockReturnValue('schema_and_log_and_data') + mockGetProjectSettings.mockResolvedValue({ is_sensitive: true }) + + const result = await getAIDetails({ + orgSlug: ORG_SLUG, + projectRef: PROJECT_REF, + authorization: AUTH, }) - it('disables the opt-in level for a sensitive project', async () => { - mockGetProjectSettings.mockResolvedValue({ is_sensitive: true }) - - const result = await getAIDetails({ - orgSlug: ORG_SLUG, - projectRef: PROJECT_REF, - authorization: AUTH, - }) - - expect(result.aiOptInLevel).toBe('disabled') - expect(result.hasHipaaAddon).toBe(true) - expect(result.isRestrictedByHipaa).toBe(true) - }) - - it('disables the opt-in level when project sensitivity is unknown', async () => { - mockGetProjectSettings.mockResolvedValue(undefined) - - const result = await getAIDetails({ - orgSlug: ORG_SLUG, - projectRef: PROJECT_REF, - authorization: AUTH, - }) - - expect(result.aiOptInLevel).toBe('disabled') - }) - - it('keeps the opt-in level for a project explicitly marked not sensitive', async () => { - mockGetProjectSettings.mockResolvedValue({ is_sensitive: false }) - - const result = await getAIDetails({ - orgSlug: ORG_SLUG, - projectRef: PROJECT_REF, - authorization: AUTH, - }) - - expect(result.aiOptInLevel).toBe('schema_and_log_and_data') - }) + expect(result.aiOptInLevel).toBe('schema_and_log_and_data') + expect(result.hasHipaaAddon).toBe(true) + expect(result.isSensitive).toBe(true) }) it('keeps the opt-in level for a sensitive project outside a HIPAA org', async () => { diff --git a/apps/studio/lib/ai/ai-details.ts b/apps/studio/lib/ai/ai-details.ts index 5452433c544..63db823ff32 100644 --- a/apps/studio/lib/ai/ai-details.ts +++ b/apps/studio/lib/ai/ai-details.ts @@ -15,8 +15,6 @@ export type AIDetails = { planId: string | undefined region: string | undefined isSensitive: boolean | null | undefined - // True when HIPAA forced the opt-in level to `disabled`, rather than the org choosing it. - isRestrictedByHipaa: boolean } // Resolves the AI opt-in level, model access and tracing inputs for one org/project pair. @@ -63,17 +61,13 @@ export const getAIDetails = async ({ planId: undefined, region, isSensitive, - isRestrictedByHipaa: false, } } const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - // Mirrors the client-side gate in useOrgAiOptInLevel, which had no server-side equivalent - const isRestrictedByHipaa = hasHipaaAddon && isSensitive !== false - return { - aiOptInLevel: isRestrictedByHipaa ? 'disabled' : getAiOptInLevel(selectedOrg.opt_in_tags), + aiOptInLevel: getAiOptInLevel(selectedOrg.opt_in_tags), hasAccessToAdvanceModel: advanceModelAccess.hasAccess, hasHipaaAddon, orgId: selectedOrg.id, @@ -81,6 +75,5 @@ export const getAIDetails = async ({ planId: selectedOrg.plan.id, region, isSensitive, - isRestrictedByHipaa, } } diff --git a/apps/studio/lib/ai/tool-filter.test.ts b/apps/studio/lib/ai/tool-filter.test.ts index 47c91261876..c1c7cd9e51c 100644 --- a/apps/studio/lib/ai/tool-filter.test.ts +++ b/apps/studio/lib/ai/tool-filter.test.ts @@ -38,7 +38,7 @@ describe('tool allowance by opt-in level', () => { query_logs: { execute: vitest.fn().mockResolvedValue({ status: 'success' }) }, } as unknown as ToolSet - const filtered = filterToolsByOptInLevel(mockTools, optInLevel as any, false) + const filtered = filterToolsByOptInLevel(mockTools, optInLevel as any) const allowedTools: string[] = [] Object.entries(filtered).forEach(([toolName, tool]) => { @@ -163,14 +163,14 @@ describe('filterToolsByOptInLevel', () => { } it('should filter out unknown tools entirely', async () => { - const tools = filterToolsByOptInLevel(mockTools, 'disabled', false) + const tools = filterToolsByOptInLevel(mockTools, 'disabled') // Unknown tools should be completely filtered out (not present in result) expect(tools).not.toHaveProperty('some_other_tool') }) it('should always allow UI tools regardless of opt-in level', async () => { - const tools = filterToolsByOptInLevel(mockTools, 'disabled', false) + const tools = filterToolsByOptInLevel(mockTools, 'disabled') expect(tools).toHaveProperty('execute_sql') expect(tools).toHaveProperty('deploy_edge_function') @@ -189,7 +189,7 @@ describe('filterToolsByOptInLevel', () => { }) it('should stub all managed tools for disabled opt-in level', async () => { - const tools = filterToolsByOptInLevel(mockTools, 'disabled', false) + const tools = filterToolsByOptInLevel(mockTools, 'disabled') await expectStubsFor(tools, [ 'list_tables', @@ -203,7 +203,7 @@ describe('filterToolsByOptInLevel', () => { }) it('should stub log tools for schema opt-in level', async () => { - const tools = filterToolsByOptInLevel(mockTools, 'schema', false) + const tools = filterToolsByOptInLevel(mockTools, 'schema') await expectStubsFor(tools, ['get_advisors', 'query_logs']) }) @@ -211,7 +211,7 @@ describe('filterToolsByOptInLevel', () => { // No execute_sql tool, so nothing additional to stub for schema_and_log opt-in level it('should not stub any tools for schema_and_log_and_data opt-in level', async () => { - const tools = filterToolsByOptInLevel(mockTools, 'schema_and_log_and_data', false) + const tools = filterToolsByOptInLevel(mockTools, 'schema_and_log_and_data') await expectStubsFor(tools, []) }) @@ -226,7 +226,7 @@ describe('createPrivacyMessageTool', () => { toModelOutput: vitest.fn(), } - const privacyTool = createPrivacyMessageTool(originalTool, false) + const privacyTool = createPrivacyMessageTool(originalTool) expect(privacyTool.description).toContain('Original description') expect(privacyTool.description).toContain('Requires opting in') @@ -236,24 +236,6 @@ describe('createPrivacyMessageTool', () => { expect(result.status).toContain("You don't have permission to use this tool") expect(result.status).toContain('third-party AI providers') }) - - it('uses HIPAA copy when the project is HIPAA-restricted', async () => { - const originalTool = { - description: 'Original description', - inputSchema: z.object({}), - execute: vitest.fn(), - } - - const hipaaTool = createPrivacyMessageTool(originalTool, true) - const optInTool = createPrivacyMessageTool(originalTool, false) - - const hipaaStatus = (await hipaaTool.execute({}, {})).status - const optInStatus = (await optInTool.execute({}, {})).status - - expect(hipaaStatus).toContain('HIPAA') - expect(hipaaStatus).not.toEqual(optInStatus) - expect(hipaaTool.description).not.toEqual(optInTool.description) - }) }) describe('toolSetValidationSchema', () => { diff --git a/apps/studio/lib/ai/tool-filter.ts b/apps/studio/lib/ai/tool-filter.ts index d93663d8a8f..477dc761921 100644 --- a/apps/studio/lib/ai/tool-filter.ts +++ b/apps/studio/lib/ai/tool-filter.ts @@ -167,16 +167,11 @@ function isToolAllowed(toolName: string, aiOptInLevel: AiOptInLevel): boolean { * Names no provider. Studio switches inference providers, and a stale name here becomes a * false privacy claim shown to users. */ -export function createPrivacyMessageTool( - toolInstance: Tool, - isRestrictedByHipaa: boolean -) { - const privacyMessage = isRestrictedByHipaa - ? "You don't have permission to use this tool. This project is configured as High Compliance and your organization has the HIPAA add-on, so Supabase does not send project metadata with your prompts. The user cannot change this in their organization's AI settings, so continue without project metadata rather than asking them to opt in." - : "You don't have permission to use this tool. This is an organization-wide setting requiring you to opt-in. Please choose your preferred data sharing level in your organization's settings. By default, no data is shared. Granting permission allows Supabase to send information (like schema, logs, or data, depending on your chosen level) to third-party AI providers solely to generate responses." - const condensedPrivacyMessage = isRestrictedByHipaa - ? 'Unavailable because this project is configured as High Compliance, which prevents project metadata from being shared. This cannot be changed in organization settings.' - : 'Requires opting in to sharing data with third-party AI providers. You can opt in via organization settings.' +export function createPrivacyMessageTool(toolInstance: Tool) { + const privacyMessage = + "You don't have permission to use this tool. This is an organization-wide setting requiring you to opt-in. Please choose your preferred data sharing level in your organization's settings. By default, no data is shared. Granting permission allows Supabase to send information (like schema, logs, or data, depending on your chosen level) to third-party AI providers solely to generate responses." + const condensedPrivacyMessage = + 'Requires opting in to sharing data with third-party AI providers. You can opt in via organization settings.' const toolDescription = toolInstance.description const description = typeof toolDescription === 'function' @@ -197,11 +192,7 @@ export function createPrivacyMessageTool( /** * Filter tools based on the AI opt-in level */ -export function filterToolsByOptInLevel( - tools: ToolSet, - aiOptInLevel: AiOptInLevel, - isRestrictedByHipaa: boolean -): ToolSet { +export function filterToolsByOptInLevel(tools: ToolSet, aiOptInLevel: AiOptInLevel): ToolSet { return Object.fromEntries( Object.entries(tools) .filter(([toolName]) => TOOL_CATEGORY_MAP[toolName] !== undefined) @@ -211,7 +202,7 @@ export function filterToolsByOptInLevel( } // If the tool is not allowed, provide a stub that returns a privacy message - return [toolName, createPrivacyMessageTool(toolInstance, isRestrictedByHipaa)] + return [toolName, createPrivacyMessageTool(toolInstance)] }) ) } diff --git a/apps/studio/lib/ai/tools/index.test.ts b/apps/studio/lib/ai/tools/index.test.ts index fcf580f2446..c0157b35069 100644 --- a/apps/studio/lib/ai/tools/index.test.ts +++ b/apps/studio/lib/ai/tools/index.test.ts @@ -18,7 +18,6 @@ const BASE_PARAMS = { connectionString: 'postgresql://localhost', authorization: 'Bearer token', aiOptInLevel: 'schema_and_log_and_data' as const, - isRestrictedByHipaa: false, accessToken: 'access-token', baseUrl: 'https://supabase.com/dashboard', signal: new AbortController().signal, @@ -40,7 +39,6 @@ describe('ai/tools getTools', () => { accessToken: BASE_PARAMS.accessToken, projectRef: BASE_PARAMS.projectRef, aiOptInLevel: BASE_PARAMS.aiOptInLevel, - isRestrictedByHipaa: BASE_PARAMS.isRestrictedByHipaa, signal: BASE_PARAMS.signal, }) expect(tools).toHaveProperty('studio_tool') diff --git a/apps/studio/lib/ai/tools/index.ts b/apps/studio/lib/ai/tools/index.ts index b4b0eabbc1f..b4b041a78c6 100644 --- a/apps/studio/lib/ai/tools/index.ts +++ b/apps/studio/lib/ai/tools/index.ts @@ -17,7 +17,6 @@ export const getTools = async ({ connectionString, authorization, aiOptInLevel, - isRestrictedByHipaa, accessToken, baseUrl, supportMode, @@ -28,8 +27,6 @@ export const getTools = async ({ connectionString: string authorization?: string aiOptInLevel: AiOptInLevel - // Only changes the blocked-tool wording. - isRestrictedByHipaa: boolean accessToken?: string baseUrl?: string supportMode?: boolean @@ -66,7 +63,6 @@ export const getTools = async ({ accessToken, projectRef, aiOptInLevel, - isRestrictedByHipaa, signal, }) } catch (error) { @@ -95,11 +91,7 @@ export const getTools = async ({ // Filter all tools based on the (potentially modified) AI opt-in level const toolsWithSupport = supportMode ? { ...tools, ...getSupportLifecycleTools() } : tools - const filteredTools: ToolSet = filterToolsByOptInLevel( - toolsWithSupport, - aiOptInLevel, - isRestrictedByHipaa - ) + const filteredTools: ToolSet = filterToolsByOptInLevel(toolsWithSupport, aiOptInLevel) return filteredTools } diff --git a/apps/studio/lib/ai/tools/mcp-tools.test.ts b/apps/studio/lib/ai/tools/mcp-tools.test.ts index 538c2d16084..349c642c71a 100644 --- a/apps/studio/lib/ai/tools/mcp-tools.test.ts +++ b/apps/studio/lib/ai/tools/mcp-tools.test.ts @@ -11,7 +11,6 @@ const BASE_PARAMS = { accessToken: 'token', projectRef: 'abcdefghijklmnopqrst', aiOptInLevel: 'schema_and_log_and_data' as const, - isRestrictedByHipaa: false, // A fresh, non-aborted signal by default; lifecycle tests override it signal: new AbortController().signal, } diff --git a/apps/studio/lib/ai/tools/mcp-tools.ts b/apps/studio/lib/ai/tools/mcp-tools.ts index 27b1174d969..d14043528a7 100644 --- a/apps/studio/lib/ai/tools/mcp-tools.ts +++ b/apps/studio/lib/ai/tools/mcp-tools.ts @@ -41,13 +41,11 @@ export const getMcpTools = async ({ accessToken, projectRef, aiOptInLevel, - isRestrictedByHipaa, signal, }: { accessToken: string projectRef: string aiOptInLevel: AiOptInLevel - isRestrictedByHipaa: boolean // Required: the remote client holds an HTTP connection that must be torn down // when the request ends. The caller owns that lifecycle via this signal. signal: AbortSignal @@ -101,11 +99,7 @@ export const getMcpTools = async ({ // write/destructive tools (apply_migration, create_branch, ...) from reaching // the assistant. `read_only` is defense-in-depth (those tools throw at // runtime). Do not remove this filter on the assumption `read_only` suffices. - const allowedMcpTools = filterToolsByOptInLevel( - availableMcpTools, - aiOptInLevel, - isRestrictedByHipaa - ) + const allowedMcpTools = filterToolsByOptInLevel(availableMcpTools, aiOptInLevel) // Remove UI-executed tools handled locally const filteredMcpTools: ToolSet = { ...allowedMcpTools } diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 59998e32472..b68ba949bc3 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -120,7 +120,6 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw const includesLogsSnippets = messagesIncludeLogsSnippets(messages) let aiOptInLevel: AiOptInLevel = 'disabled' - let isRestrictedByHipaa = false let hasAccessToAdvanceModel = false let orgHasHipaaAddon: boolean | undefined let projectIsSensitive: boolean | null | undefined @@ -139,7 +138,6 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw const aiDetails = await getAIDetails({ orgSlug: rawOrgSlug, projectRef, authorization }) aiOptInLevel = aiDetails.aiOptInLevel - isRestrictedByHipaa = aiDetails.isRestrictedByHipaa hasAccessToAdvanceModel = aiDetails.hasAccessToAdvanceModel orgHasHipaaAddon = aiDetails.hasHipaaAddon orgId = aiDetails.orgId @@ -189,7 +187,6 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw connectionString, authorization, aiOptInLevel, - isRestrictedByHipaa, accessToken, baseUrl: getURL(), supportMode, diff --git a/apps/studio/pages/api/ai/sql/policy.ts b/apps/studio/pages/api/ai/sql/policy.ts index 9f70dbf432c..6edc896bd37 100644 --- a/apps/studio/pages/api/ai/sql/policy.ts +++ b/apps/studio/pages/api/ai/sql/policy.ts @@ -73,7 +73,6 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse, clai const { tableName, schema, columns = [], projectRef, connectionString, orgSlug, message } = data let aiOptInLevel: AiOptInLevel = 'disabled' - let isRestrictedByHipaa = false if (!IS_PLATFORM) { aiOptInLevel = 'schema' @@ -84,7 +83,6 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse, clai const aiDetails = await getAIDetails({ orgSlug, projectRef, authorization }) aiOptInLevel = aiDetails.aiOptInLevel - isRestrictedByHipaa = aiDetails.isRestrictedByHipaa } catch (error) { return res.status(400).json({ error: 'There was an error fetching your organization details', @@ -119,7 +117,6 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse, clai connectionString, authorization, aiOptInLevel, - isRestrictedByHipaa, accessToken, isExplorerEnabled: explorerEnabled, signal: toolsAbortController.signal, From d72a29852c75a42665182aa62bb17cb01d75f719 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 18 Sep 2026 07:15:59 -0600 Subject: [PATCH 12/31] fix(studio): reject custom log time ranges outside the Date range (#50539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a 9-digit amount into the logs date picker's custom field built a "Last N days" helper that subtracted past the representable `Date` range, so `toISOString()` threw `RangeError: Invalid time value` while rendering the helper list — crashing both Unified Logs and Logs Explorer. `parseCustomInput` now rejects those amounts, so oversized input behaves like any other invalid input (empty helper list) instead of producing a helper that throws. Fixes FE-4426 ## Summary by CodeRabbit - **Bug Fixes** - Logs date filters now reject excessively large day values outside the supported date range. - Invalid date inputs no longer generate unusable date filter options. - The date picker now displays guidance when an invalid custom format produces no matching options. - **Tests** - Added coverage for out-of-range values and confirmed valid large date ranges continue to work correctly. --------- Co-authored-by: Joshen Lim --- .../Settings/Logs/Logs.DatePickers.tsx | 5 +++++ .../Settings/Logs/Logs.datePickerHelpers.ts | 5 ++--- .../features/logs/Logs.Datepickers.test.tsx | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index f5b2ab65bfe..90183fca92e 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -343,6 +343,11 @@ export const LogsDatePicker = ({ ) : null} ))} + {displayedHelpers.length === 0 && ( +

+ Invalid format. Try 2h, 30m, or 7d. +

+ )}
diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.datePickerHelpers.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.datePickerHelpers.ts index 422a2f04033..76271b2eb50 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.datePickerHelpers.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.datePickerHelpers.ts @@ -29,9 +29,8 @@ export const parseCustomInput = (input: string): ParsedCustomInput => { const [, numStr, unitStr] = match const value = Number.parseInt(numStr, 10) - // Only finite positive values may reach generateDynamicHelper(): Number.isFinite - // rejects NaN and Infinity outright, and the <= 0 guard keeps out non-positive. - if (!Number.isFinite(value) || value <= 0) return { type: 'invalid' } + const exceedsDateRange = !dayjs().subtract(value, 'day').isValid() + if (!Number.isFinite(value) || value <= 0 || exceedsDateRange) return { type: 'invalid' } if (!unitStr) { return { type: 'number', value } diff --git a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx index c9bff95574f..639961eb08e 100644 --- a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx +++ b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx @@ -67,6 +67,12 @@ describe('parseCustomInput', () => { expect(parseCustomInput('0')).toEqual({ type: 'invalid' }) expect(parseCustomInput('-5')).toEqual({ type: 'invalid' }) }) + + test('returns invalid for amounts that fall outside the representable date range', () => { + expect(parseCustomInput('999999999')).toEqual({ type: 'invalid' }) + expect(parseCustomInput('999999999d')).toEqual({ type: 'invalid' }) + expect(parseCustomInput('99999999')).toEqual({ type: 'number', value: 99999999 }) + }) }) describe('generateDynamicHelper', () => { @@ -125,6 +131,16 @@ describe('generateHelpersFromInput', () => { expect(helpers).toHaveLength(1) expect(helpers![0].text).toBe('Last 2 hours') }) + + test('never returns a helper whose calcFrom throws', () => { + expect(generateHelpersFromInput('999999999')).toBeNull() + + const helpers = generateHelpersFromInput('99999999') + expect(helpers).not.toBeNull() + for (const helper of helpers!) { + expect(() => helper.calcFrom()).not.toThrow() + } + }) }) const mockFn = vi.fn() From 45a80b56856f47dfc07d77660bccc5349f5492f3 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 18 Sep 2026 07:16:18 -0600 Subject: [PATCH 13/31] fix(studio): price nano compute at the micro rate in restore to new project (#50546) Restore to new project showed $0 Additional Monthly Compute for nano projects on paid plans, because the cost estimate hardcoded nano and pico to $0 regardless of plan. It now prices them at the micro rate on paid plans, matching how they're billed (and how Disk Management already displays them). Fixes FE-4427 ## Summary by CodeRabbit - **Bug Fixes** - Corrected monthly pricing estimates when restoring a project with pico or nano compute sizes on paid plans. - Free plans continue to show no compute charge. - Pricing for micro and small compute sizes remains calculated using their expected rates. - **Tests** - Added coverage for compute pricing across free and paid plans and multiple instance sizes. --- .../RestoreToNewProject.utils.test.ts | 35 +++++++++++++++++++ .../RestoreToNewProject.utils.ts | 6 ++-- 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils.test.ts diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils.test.ts b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils.test.ts new file mode 100644 index 00000000000..72422a069c5 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { projectSpecToMonthlyPrice } from './RestoreToNewProject.utils' +import { InfraInstanceSize } from '@/components/interfaces/DiskManagement/DiskManagement.types' +import { DiskType } from '@/components/interfaces/DiskManagement/ui/DiskManagement.constants' +import { PlanId } from '@/data/subscriptions/types' + +const getComputePrice = (targetComputeSize: InfraInstanceSize, planId: PlanId) => + projectSpecToMonthlyPrice({ + targetVolumeSizeGb: 8, + targetComputeSize, + planId, + storageType: DiskType.GP3, + }).computePrice + +describe('projectSpecToMonthlyPrice', () => { + it('prices nano at the micro rate on paid plans', () => { + expect(getComputePrice('nano', 'pro')).toBe(9.68) + expect(getComputePrice('nano', 'team')).toBe(9.68) + }) + + it('prices pico at the micro rate on paid plans', () => { + expect(getComputePrice('pico', 'pro')).toBe(9.68) + }) + + it('prices nano and pico at zero on the free plan', () => { + expect(getComputePrice('nano', 'free')).toBe(0) + expect(getComputePrice('pico', 'free')).toBe(0) + }) + + it('prices sizes above nano from their own compute rate', () => { + expect(getComputePrice('micro', 'pro')).toBe(9.68) + expect(getComputePrice('small', 'pro')).toBe(14.83) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils.ts b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils.ts index a27a42288ed..d33f955d033 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils.ts +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils.ts @@ -42,7 +42,7 @@ export function projectSpecToMonthlyPrice({ const computePrice = calculateComputeSizePrice({ availableOptions: [ - { identifier: targetComputeSize, price: getComputeHourlyPrice(targetComputeSize) }, + { identifier: targetComputeSize, price: getComputeHourlyPrice(targetComputeSize, planId) }, ], oldComputeSize: 'nano', // not used for r2np newComputeSize: targetComputeSize, @@ -55,9 +55,9 @@ export function projectSpecToMonthlyPrice({ } } -function getComputeHourlyPrice(computeSize: InfraInstanceSize): number { +function getComputeHourlyPrice(computeSize: InfraInstanceSize, planId: PlanId): number { if (computeSize === 'pico' || computeSize === 'nano') { - return 0 + return planId === 'free' ? 0 : instanceSizeSpecs.micro.priceHourly } return instanceSizeSpecs[computeSize]?.priceHourly From a536a8fdd07e4232e784c019ae1d48ae08c5386b Mon Sep 17 00:00:00 2001 From: Victor Farazdagi Date: Fri, 18 Sep 2026 16:19:20 +0300 Subject: [PATCH 14/31] docs(pipelines): add Snowflake materialization examples (#50571) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Documentation update. ## What is the current behavior? The Snowflake destination guide describes its append-only change history, but does not include SQL examples for querying current state or maintaining a materialized result. ## What is the new behavior? Add a "Query and materialize current state" section with: - A query and reusable view that select the latest event per identity before filtering deletes. - An incremental dynamic-table example with a configurable freshness target. - Guidance on stable keys, permissions, change tracking, refresh costs, and recovery after table resets or schema changes. - Links to official Snowflake documentation, including the streams-and-tasks alternative. ## Summary by CodeRabbit * **Documentation** * Expanded Snowflake replication guidance for deriving current state from append-only change history. * Added examples for identity selection, `QUALIFY`-based filtering, reusable views, dynamic tables, streams, and tasks. * Documented considerations for mutable identity columns, delete handling, change tracking permissions, refresh settings, target lag, and DDL effects. * Clarified that change tracking must be enabled before altering managed objects. --- .../guides/database/replication/snowflake.mdx | 139 +++++++++++++++++- 1 file changed, 137 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/guides/database/replication/snowflake.mdx b/apps/docs/content/guides/database/replication/snowflake.mdx index 1f4662b8f9a..db0af2240fd 100644 --- a/apps/docs/content/guides/database/replication/snowflake.mdx +++ b/apps/docs/content/guides/database/replication/snowflake.mdx @@ -154,10 +154,145 @@ Snowflake tables are an event history, not a current-state replica: - A delete appends the complete old row for `REPLICA IDENTITY FULL`. For a primary-key or `USING INDEX` identity, it appends only the identity columns and sets all other source columns to `NULL`. - A source `TRUNCATE` truncates the Snowflake table, resets its streaming state, and does not append a truncate event. -To derive current state, group by a stable source identity and select the row with the latest `_cdc_sequence_number`. Exclude identities whose latest operation is `delete`. The sequence number is used for ordering and checkpointing. It is not a globally unique event ID. Pipelines provides at-least-once delivery, so consumers must tolerate duplicates. Snowpipe committed offsets suppress routine replay but do not change this guarantee. +To derive current state, group by a stable source identity and select the row with the latest `_cdc_sequence_number`. Exclude identities whose latest operation is `delete`. See [Query and materialize current state](#query-and-materialize-current-state) for SQL examples. + +The sequence number is used for ordering and checkpointing. It is not a globally unique event ID. Pipelines provides at-least-once delivery, so consumers must tolerate duplicates. Snowpipe committed offsets suppress routine replay but do not change this guarantee. Resetting a table drops and recreates its Snowflake table and managed streaming state. This erases its history. Removing a table from the Postgres publication stops new changes after the pipeline restarts. The existing Snowflake table remains. +## Query and materialize current state + +Use the replicated change history to build a current-state dataset for reports and analytics. Pipelines maintains the history table. You create and maintain the queries, views, or dynamic tables that read it. + +| Approach | When to use it | Tradeoff | +| -------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [Query or view](#query-current-state) | Read current state from the changes already in Snowflake. | Computes the result when queried, so query cost can grow with the history. | +| [Dynamic table](#materialize-with-a-dynamic-table) | Store current state for repeated analytics queries. | Uses compute and storage to maintain the result, with a configurable freshness target. | +| [Streams and tasks](#use-streams-and-tasks) | Control how and when a separate table is updated. | Requires your own merge, initialization, and recovery logic. | + +### Before you start + +The examples use `public.orders`, replicated to `PIPELINES_DB.REPLICATED.PUBLIC_ORDERS`, with source columns `id` and `status`. Replace these names with your own. Wait for the table's initial sync to finish before treating the result as a complete replica. + +Choose a unique, non-null identity that stays the same when a row is updated. The examples use `id`. For a composite key, include every key column in `partition by`, such as `partition by "tenant_id", "id"`. Include those columns in the publication and in delete events. `REPLICA IDENTITY FULL` alone does not make rows unique. + + + +Changing an identity column can leave the old identity in these results. Pipelines appends the new row for an update without a delete for the previous identity. Use an immutable key for this pattern. + + + +Use a separate analytics role and warehouse, with a schema outside the Pipelines-managed `REPLICATED` schema for derived objects. The examples use `ANALYTICS_ROLE`, `ANALYTICS_WH`, and `PIPELINES_DB.ANALYTICS`. Ask your Snowflake administrator to prepare these resources and grant the analytics role: + +- `USAGE` on the warehouse, database, and both schemas. +- `SELECT` on the replicated table. +- `CREATE VIEW` on the analytics schema to create a view, or `CREATE DYNAMIC TABLE` to create a dynamic table. + +The role must be available to the Snowflake user running the examples. Keep ownership of the replicated table with `PIPELINES_ROLE`. See Snowflake's [dynamic table access control](https://docs.snowflake.com/en/user-guide/dynamic-tables/privileges) for the full privilege requirements. + +### Query current state + +Run these statements in a Snowflake SQL worksheet with your analytics role: + +```sql +use role ANALYTICS_ROLE; +use warehouse ANALYTICS_WH; + +select "id", "status" +from PIPELINES_DB.REPLICATED.PUBLIC_ORDERS +qualify row_number() over ( + partition by "id" order by "_cdc_sequence_number" desc +) = 1 +and "_cdc_operation" != 'delete'; +``` + +The result contains one row per identity whose latest operation is not `delete`. Ordering by the fixed-width sequence string selects the latest change. Repeated copies of the same event produce one result row. Keep the double quotes around source and metadata column names because Pipelines creates them as case-sensitive identifiers. + +Keep the delete condition in `qualify`. A `where "_cdc_operation" != 'delete'` condition would remove delete events before ranking and could bring back an older row. Snowflake's [`QUALIFY` reference](https://docs.snowflake.com/en/sql-reference/constructs/qualify) explains this evaluation order. + +To reuse the query from an analytics tool, save it as a view: + +```sql +create view PIPELINES_DB.ANALYTICS.ORDERS_CURRENT_VIEW as +select "id", "status" +from PIPELINES_DB.REPLICATED.PUBLIC_ORDERS +qualify row_number() over ( + partition by "id" order by "_cdc_sequence_number" desc +) = 1 +and "_cdc_operation" != 'delete'; +``` + +A regular view stores the query definition, not a separate copy of its results. Each read derives current state from the history available to that query. See Snowflake's [comparison of views and dynamic tables](https://docs.snowflake.com/en/user-guide/overview-view-mview-dts). + +### Materialize with a dynamic table + +A dynamic table stores the query result and refreshes it as the replicated history changes. Use it when you want to query a maintained current-state dataset without defining a scheduled merge task. + +1. Ask the owner of the replicated table to enable change tracking in Snowflake. This is a table setting, not a change to the replicated columns or data. Run as `PIPELINES_ROLE`, or another role that inherits ownership: + + ```sql + alter table PIPELINES_DB.REPLICATED.PUBLIC_ORDERS + set change_tracking = true; + ``` + + The analytics role does not own the replicated table, so it cannot enable change tracking automatically when creating the dynamic table. See Snowflake's [change tracking requirements](https://docs.snowflake.com/en/user-guide/dynamic-tables/troubleshoot-creation#change-tracking-not-enabled-on-base-tables). + +2. Switch to the analytics role and create the dynamic table: + + ```sql + use role ANALYTICS_ROLE; + use warehouse ANALYTICS_WH; + + create dynamic table PIPELINES_DB.ANALYTICS.ORDERS_CURRENT + target_lag = '5 minutes' + warehouse = ANALYTICS_WH + refresh_mode = incremental + initialize = on_create + as + select "id", "status" + from PIPELINES_DB.REPLICATED.PUBLIC_ORDERS + qualify row_number() over ( + partition by "id" order by "_cdc_sequence_number" desc + ) = 1 + and "_cdc_operation" != 'delete'; + ``` + + `initialize = on_create` populates the dynamic table before creation finishes. Explicit `refresh_mode = incremental` makes creation fail if your adapted query cannot refresh incrementally, instead of choosing a full refresh through `AUTO`. See Snowflake's [refresh modes](https://docs.snowflake.com/en/user-guide/dynamic-tables/refresh-modes) and [`CREATE DYNAMIC TABLE` reference](https://docs.snowflake.com/en/sql-reference/sql/create-dynamic-table). + +3. Check the refresh mode and read the materialized rows: + + ```sql + show dynamic tables like 'ORDERS_CURRENT' + in schema PIPELINES_DB.ANALYTICS; + + select "id", "status" + from PIPELINES_DB.ANALYTICS.ORDERS_CURRENT; + ``` + + Confirm that `refresh_mode` is `INCREMENTAL` and scheduling is running. Use [Snowflake's refresh monitoring](https://docs.snowflake.com/en/user-guide/dynamic-tables/monitoring) to check the last successful refresh and any errors. After an insert, update, or delete reaches the replicated table, the next successful refresh reflects it in `ORDERS_CURRENT`. + +The five-minute `target_lag` is an example freshness target relative to the history in Snowflake. It is not a fixed refresh schedule or an end-to-end latency guarantee from Postgres. Pipeline replication lag and dynamic-table refresh lag both affect freshness. See Snowflake's [target lag guide](https://docs.snowflake.com/en/user-guide/dynamic-tables/target-lag). + +Dynamic-table refreshes consume warehouse compute, and the materialized results consume storage. These costs are additional to ingestion and querying. Start with a freshness target that meets your reporting needs and measure a representative workload. A dedicated warehouse helps isolate refresh costs. See Snowflake's [dynamic table cost guide](https://docs.snowflake.com/en/user-guide/dynamic-tables/cost). + +### Maintain derived objects + +Pipelines maintains the replicated history table, but does not update your view or dynamic-table definitions. + +| Change | What to do | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Source `TRUNCATE` | A direct query or view reads the truncated history. Check that the dynamic table completes a refresh before relying on its contents. | +| Pipeline table reset | Wait for the new initial sync. Reapply table-specific read grants and change tracking to the recreated history table. Check dependent objects and recreate the dynamic table if it cannot refresh. | +| Added, renamed, or dropped source column | Review the explicit column list. Add new columns to your definition when needed. Update or recreate derived objects that reference renamed or dropped columns. | + +Recreating a dynamic table initializes its contents again and uses compute. See Snowflake's [dynamic table modification guide](https://docs.snowflake.com/en/user-guide/dynamic-tables/modify) for changes that require reinitialization. + +### Use streams and tasks + +Snowflake [streams and tasks](https://docs.snowflake.com/en/user-guide/data-pipelines-intro) can maintain a separate table with scheduled `MERGE` statements. Use this option when you need control over the update procedure or schedule. Snowflake's [SCD Type 1 examples](https://docs.snowflake.com/en/user-guide/dynamic-tables/migrate-streams-tasks#scd-type-1-upsert) compare this approach with dynamic tables. + +Adapt the merge to Pipelines' `"_cdc_operation"` and `"_cdc_sequence_number"` columns. A stream on the history table sees appended rows, including rows representing source updates and deletes. Your job must interpret those operations, load existing history, tolerate replay, and rebuild current state after a source truncate or pipeline table reset. + ## Source table requirements Required `REPLICA IDENTITY` depends on the operations enabled in the Postgres publication: @@ -218,7 +353,7 @@ Unsupported or limited changes: - Changes to nullability or existing column defaults are ignored. - Initial table creation can copy compatible literal defaults. Added columns can copy string, numeric, or boolean literal defaults. Other defaults are omitted. -Snowflake DDL changes existing history. Adding a column with a default can populate older rows. Renaming a column changes the historical schema. Dropping a column removes it from old events. Snowflake DDL is not transactional, so an interrupted multi-column change can leave a partially applied schema. Do not alter managed destination objects manually. If the pipeline remains failed after a restart, [contact support](/dashboard/support/new). +Snowflake DDL changes existing history. Adding a column with a default can populate older rows. Renaming a column changes the historical schema. Dropping a column removes it from old events. Snowflake DDL is not transactional, so an interrupted multi-column change can leave a partially applied schema. Apart from [enabling change tracking](#materialize-with-a-dynamic-table), do not alter managed destination objects manually. If the pipeline remains failed after a restart, [contact support](/dashboard/support/new). ## Troubleshooting From c5f0ba40348dd185af86fb26d1465a5e49d7d0eb Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 18 Sep 2026 15:29:10 +0200 Subject: [PATCH 15/31] fix(docs): redirect monitoring-and-debugging to observability (#50576) This PR will also fix the markdown redirects. There's no need to list markdown redirects, `.md` is automatically added when building the redirects in `www` `next.config.mjs`. ## Summary by CodeRabbit - **Bug Fixes** - Updated observability documentation redirects to use extension-free URLs. - Removed the outdated redirect from the observability access-data page. - Updated the monitoring and debugging redirect to point to the extension-free observability guide. --- apps/www/lib/redirects.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 553e683038d..6e5f7ba0b36 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -190,11 +190,6 @@ module.exports = [ source: '/docs/guides/observability/access-data', destination: '/docs/guides/observability', }, - { - permanent: true, - source: '/docs/guides/observability/access-data.md', - destination: '/docs/guides/observability.md', - }, { permanent: true, source: '/docs/guides/reports/:match*', @@ -207,8 +202,8 @@ module.exports = [ }, { permanent: true, - source: '/docs/guides/monitoring-and-debugging.md', - destination: '/docs/guides/observability.md', + source: '/docs/guides/monitoring-and-debugging', + destination: '/docs/guides/observability', }, { permanent: true, From 823d4d09919520c8699b47b78866581a2a757f35 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:39:24 +0200 Subject: [PATCH 16/31] build: disable sentry source maps for deployment test (#50579) ## Problem Next.js deployments can stall after compilation while Sentry performs post-compile source-map processing. We need a controlled deployment test to isolate that phase. ## Fix Disable Sentry source-map generation and upload for Studio, Docs, and WWW without changing Sentry logging, dependencies, or other build configuration. ## How to test - Deploy Preview builds for Studio, Docs, and WWW. - Confirm each build passes the post-compile phase. - Expected result: the builds complete without running Sentry source-map processing. ## Summary by CodeRabbit * **Chores** * Updated production build configuration to use the default output mode. * Continued disabling source map handling in production. --- apps/studio/next.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index 929cde8b773..93ec09ea906 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -221,6 +221,7 @@ export default process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && process.env.VER ? withSentryConfig(platformConfig, { silent: false, debug: true, + sourcemaps: { disable: true }, // For all available options, see: // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ From 3a11d78c6f394b9261202362798956e979a13371 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:59:53 +0000 Subject: [PATCH 17/31] Revert source-map disable from #50579 (re-enable Sentry source maps for studio) (#50581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Ali Waseem** · [Slack thread](https://supabase.slack.com/archives/C0161K73J1J/p1789738618897549?thread_ts=1789738618.897549&cid=C0161K73J1J)_ **Before:** #50579 disabled Sentry source-map generation and upload for `apps/studio` (`sourcemaps: { disable: true }` in `apps/studio/next.config.ts`), as a same-day attempt to fix Vercel Preview builds OOMing during compilation. **After:** Sentry source maps are re-enabled for `apps/studio` by removing that option, restoring the file to its exact pre-#50579 state for this line. Disabling source maps turned out not to fix the OOM issue after all — builds still hung. The actual fix was switching Vercel to Elastic Build Machines (an infrastructure setting, not a code change), which brought build times down to ~4 minutes. Since source maps are valuable for Sentry crash visibility and are no longer needed to work around the OOM, this PR re-enables them. Note on scope: `output: 'standalone'` in `apps/studio/next.config.ts` is left untouched by this PR. It was removed and then re-added within #50579 itself (net no change on merge), and it remains present (`output: 'standalone'`) on the current default branch HEAD. Any further discussion about removing `output: 'standalone'` (raised separately in the Slack thread) is intentionally out of scope here. This is a minimal, surgical revert of only the sourcemaps-disable line from #50579 — it does not touch #50578 (an unrelated Next.js/dependency version bump) or any other change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UqvBiopu7KUqAkQNvEnkoJ --- _Generated by [Claude Code](https://claude.ai/code/session_01UqvBiopu7KUqAkQNvEnkoJ)_ Co-authored-by: Claude --- apps/studio/next.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index 93ec09ea906..929cde8b773 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -221,7 +221,6 @@ export default process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && process.env.VER ? withSentryConfig(platformConfig, { silent: false, debug: true, - sourcemaps: { disable: true }, // For all available options, see: // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ From 170cda129556faa271aee792b169a2de66205de4 Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:11:30 +0100 Subject: [PATCH 18/31] Changed a brand on the State of Startups page (#50583) Replace Datapods with General Evidence and update its link to https://www.generalevidence.com in the State of Startups 2026 participant list. Keep the list in alphabetical order. ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Content correction. ## What is the current behavior? The participant list displays Datapods and links to https://datapods.app. ## What is the new behavior? The participant list displays General Evidence and links to https://www.generalevidence.com. ## Additional context Validation: - All 149 tests passed across 11 test files. - Prettier and git diff checks passed. - App lint completed with zero errors and 1,173 warnings. - Local build stopped during the docs prebuild step because GitHub credentials were missing from the build environment. ## Summary by CodeRabbit * **Updates** * Replaced the Datapods participant with General Evidence in the State of Startups survey participant list. --- apps/www/data/surveys/state-of-startups-participants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/data/surveys/state-of-startups-participants.ts b/apps/www/data/surveys/state-of-startups-participants.ts index 8536778d2d3..fb34484748a 100644 --- a/apps/www/data/surveys/state-of-startups-participants.ts +++ b/apps/www/data/surveys/state-of-startups-participants.ts @@ -55,7 +55,6 @@ export const participants: Participant[] = [ { company: 'Courage Library', url: 'https://couragelibrary.in' }, { company: 'CRONIA', url: 'https://cronia.co' }, { company: 'Darouta Ltd', url: 'https://darouta.com' }, - { company: 'Datapods', url: 'https://datapods.app' }, { company: 'Datatensei', url: 'https://datatensei.com' }, { company: 'Debt Clarity Tools', url: 'https://debtclaritytools.com' }, { company: 'DECODE GR', url: 'https://decode.gr' }, @@ -100,6 +99,7 @@ export const participants: Participant[] = [ { company: 'Ganttra', url: 'https://ganttra.com' }, { company: 'Gaslighting Check', url: 'https://gaslightingcheck.com' }, { company: 'Gathr', url: 'https://joingathr.com' }, + { company: 'General Evidence', url: 'https://www.generalevidence.com' }, { company: 'Geo Insights', url: 'https://geo-insights.nl' }, { company: 'Geosyncracy, Inc.', url: 'https://geosyncracy.com' }, { company: 'Get', url: 'https://getshitdone.cl' }, From 3ac4e77bd3586ac421b9a9908c7aff9076d2eb51 Mon Sep 17 00:00:00 2001 From: shaziya <99940835+shaziyabandukia@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:41:36 -0700 Subject: [PATCH 19/31] chore(www): update Partner Day venue address and schedule (#50587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Content update to the `/go/select-2026/partner-day` landing page. ## What is the current behavior? The Details section lists the location as "San Francisco, CA" with a "Venue details will be shared soon" placeholder, and the schedule only lists doors and happy hour times. ## What is the new behavior? - Location now shows the confirmed venue address: 580 20th St, San Francisco, CA (placeholder line removed) - Schedule now includes the program time: doors at 2:30 PM, program from 3:00 to 4:30 PM, happy hour from 4:30 PM ## Additional context Verified locally on the www dev server. ## Summary by CodeRabbit * **Documentation** * Updated the Partner Day landing page with the full venue address in San Francisco. * Added event timing details: doors open at 2:30 PM, with the program running from 3:00–4:30 PM. --- apps/www/_go/events/select-2026/partner-day.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/www/_go/events/select-2026/partner-day.tsx b/apps/www/_go/events/select-2026/partner-day.tsx index 772d17ec2ae..07d4cab8bb9 100644 --- a/apps/www/_go/events/select-2026/partner-day.tsx +++ b/apps/www/_go/events/select-2026/partner-day.tsx @@ -65,12 +65,10 @@ const page: GoPageInput = { The day before Supabase Select 2026

Location

-

San Francisco, CA

-

- Venue details will be shared soon -

+

580 20th St, San Francisco, CA

Time

Doors open at 2:30 PM

+

Program runs from 3:00 to 4:30 PM

Happy hour from 4:30 PM

), From 986931184cec900cd399117814e01d264b4880b4 Mon Sep 17 00:00:00 2001 From: Anthony Lio Date: Fri, 18 Sep 2026 21:43:08 +0300 Subject: [PATCH 20/31] fix(docs): kotlin codeblock sticky collision (#50566) ## What kind of change does this PR introduce? bug fix in kotlin documentation ## What is the current behavior? on scroll within the kotlin documentation, in the installing section a codeblock collide with the one below ## What is the new behavior? - fixes by updating mdx markup | state | preview | | -------|------| | before | image | ## Test 1. visit [kotlin documentation](https://supabase.com/docs/reference/kotlin/installing) ## Summary by CodeRabbit - **Documentation** - Improved the layout of Ktor installation guidance by separating engine setup instructions from the multiplatform example. - Applied the updated structure consistently across Kotlin installation documentation versions. --- apps/docs/docs/ref/kotlin/installing.mdx | 2 ++ apps/docs/docs/ref/kotlin/v1/installing.mdx | 2 ++ apps/docs/docs/ref/kotlin/v2/installing.mdx | 2 ++ 3 files changed, 6 insertions(+) diff --git a/apps/docs/docs/ref/kotlin/installing.mdx b/apps/docs/docs/ref/kotlin/installing.mdx index 9dac15be39f..51d70051a56 100644 --- a/apps/docs/docs/ref/kotlin/installing.mdx +++ b/apps/docs/docs/ref/kotlin/installing.mdx @@ -135,6 +135,8 @@ custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supab + + Multiplatform example: diff --git a/apps/docs/docs/ref/kotlin/v1/installing.mdx b/apps/docs/docs/ref/kotlin/v1/installing.mdx index f6e5c9ba8b3..d540a390794 100644 --- a/apps/docs/docs/ref/kotlin/v1/installing.mdx +++ b/apps/docs/docs/ref/kotlin/v1/installing.mdx @@ -129,6 +129,8 @@ custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supab + + Multiplatform example: diff --git a/apps/docs/docs/ref/kotlin/v2/installing.mdx b/apps/docs/docs/ref/kotlin/v2/installing.mdx index 001b5c20316..e9242a025b4 100644 --- a/apps/docs/docs/ref/kotlin/v2/installing.mdx +++ b/apps/docs/docs/ref/kotlin/v2/installing.mdx @@ -130,6 +130,8 @@ custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supab + + Multiplatform example: From 5918a69398f8ab302dc5590b81ac2d8e12031248 Mon Sep 17 00:00:00 2001 From: Anthony Lio Date: Fri, 18 Sep 2026 21:54:31 +0300 Subject: [PATCH 21/31] feat(docs): agent prompt block (#50565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? docs ui polish for the prompt panel on the docs homepage and in the quickstarts ## What is the current behavior? agent prompt renders as one run on paragraph steps read as "1. ... 2. ... 3. ..." inline making it hard to read ## What is the new behavior? - sets prompt bodies as markdown for easier readability - adds long prompts collapse - removes panel tab icons - uses panel code block ui - makes active tab has an underline on first paint `home` | state | preview | | -------|------| | before | image | | after | image | `quickstart` | state | preview | | -------|------| | before | image | | after | image | ## Summary by CodeRabbit * **New Features** * Prompts now support Markdown formatting, inline code chips, expandable content, and improved hover-reveal behavior. * Code-copy controls support customizable labels and clearer screen-reader announcements. * Prompt tabs and panels have updated styling, spacing, and active-content handling. * **UI Improvements** * Setup and agent prompts now use consistent Markdown-rendered presentation. * Prompt headings now display “Agent Prompt.” * Home page setup code blocks use a more compact layout. --- .../components/HomePageCover.constants.ts | 6 +- apps/docs/components/HomePageCover.tsx | 30 +-- apps/docs/features/ui/AgentSetup.tsx | 7 +- apps/docs/features/ui/AiPrompt.tsx | 16 +- .../ui/CodeBlock/CodeBlock.client.tsx | 18 +- apps/docs/features/ui/PromptPanel.tsx | 171 ++++++++++-------- .../markdown-schema/AiPrompt.test.ts | 2 +- .../internals/markdown-schema/AiPrompt.ts | 2 +- apps/docs/layouts/HomeLayout.tsx | 3 +- 9 files changed, 140 insertions(+), 115 deletions(-) diff --git a/apps/docs/components/HomePageCover.constants.ts b/apps/docs/components/HomePageCover.constants.ts index 14065615029..582a51fbf2a 100644 --- a/apps/docs/components/HomePageCover.constants.ts +++ b/apps/docs/components/HomePageCover.constants.ts @@ -6,4 +6,8 @@ export const setupCommand = { export const setupCommands = [setupCommand.installCli, setupCommand.installPlugin].join('\n') -export const setupPrompt = `Help me get set up with Supabase. Do the following: 1. Install the Supabase CLI as a project dev dependency with \`${setupCommand.installCli}\`, so the version is pinned per project. 2. Install the Supabase Plugin with \`${setupCommand.installPlugin}\`. 3. Review my project and determine whether Supabase is already initialized. If it is not initialized, run \`${setupCommand.initialize}\`. 4. Suggest the most relevant next steps.` +export const setupPrompt = `Help me get set up with Supabase. Do the following: +1. Install the Supabase CLI as a project dev dependency with \`${setupCommand.installCli}\`, so the version is pinned per project. +2. Install the Supabase Plugin with \`${setupCommand.installPlugin}\`. +3. Review my project and determine whether Supabase is already initialized. If it is not initialized, run \`${setupCommand.initialize}\`. +4. Suggest the most relevant next steps.` diff --git a/apps/docs/components/HomePageCover.tsx b/apps/docs/components/HomePageCover.tsx index b818ff79e4e..998b8b66236 100644 --- a/apps/docs/components/HomePageCover.tsx +++ b/apps/docs/components/HomePageCover.tsx @@ -4,45 +4,31 @@ import { Prompt, PromptContent, PromptCopy, + PromptMarkdown, PromptPanel, PromptTitle, } from '~/features/ui/PromptPanel' import { isFeatureEnabled } from 'common' -import { Sparkles, Terminal } from 'lucide-react' import { type ReactNode } from 'react' import { getCustomContent } from '../lib/custom-content/getCustomContent' import DocsCoverLogo from './DocsCoverLogo' -import { setupCommand, setupCommands, setupPrompt } from './HomePageCover.constants' +import { setupCommands, setupPrompt } from './HomePageCover.constants' const fullGettingStartedEnabled = isFeatureEnabled('docs:full_getting_started') function SetupPrompt({ cliCode }: { cliCode: ReactNode }) { return ( - - }>AI Prompt + + Agent Prompt {setupPrompt} - Help me get set up with Supabase. Do the following: 1. Install the Supabase CLI as a - project dev dependency with{' '} - - {setupCommand.installCli} - - , so the version is pinned per project. 2. Install the Supabase Plugin with{' '} - - {setupCommand.installPlugin} - - . 3. Review my project and determine whether Supabase is already initialized. If it is not - initialized, run{' '} - - {setupCommand.initialize} - - . 4. Suggest the most relevant next steps. + {setupPrompt} - }>CLI + CLI {setupCommands} {cliCode} @@ -56,8 +42,8 @@ const HomePageCover = ({ title, cliCode }: { title: string; cliCode: ReactNode } return (
-
-
+
+