- {/* Top Banner */}
-
-
-
-
-
- {/* Main Content Area */}
-
- {/* Sidebar - Only show for project pages, not account pages */}
- {!router.pathname.startsWith('/account') &&
}
- {/* Main Content with Layout Sidebar */}
-
-
- {children}
-
-
+
+
+ {/* Top Banner */}
+
+
+
+
-
+
+ {/* Main Content Area */}
+
+ {/* Sidebar - Only show for project pages, not account pages */}
+ {!router.pathname.startsWith('/account') &&
}
+ {/* Main Content with Layout Sidebar */}
+
+
+ {children}
+
+
+
+
-
-
-
+
+
+
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx
index 31f9342a360a0..3c4579f01c941 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx
@@ -15,9 +15,9 @@ import {
PopoverTrigger_Shadcn_,
} from 'ui'
-import { ASSISTANT_SUGGESTIONS } from '../HelpDropdown/HelpDropdown.constants'
-import { getSupportLinkQueryParams } from '../HelpDropdown/HelpDropdown.utils'
-import { HelpSection } from '../HelpDropdown/HelpSection'
+import { ASSISTANT_SUGGESTIONS } from '../HelpPanel/HelpPanel.constants'
+import { getSupportLinkQueryParams } from '../HelpPanel/HelpPanel.utils'
+import { HelpSection } from '../HelpPanel/HelpSection'
import { FeedbackWidget } from './FeedbackWidget'
export const FeedbackDropdown = ({ className }: { className?: string }) => {
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.tsx
deleted file mode 100644
index 8e9489008b055..0000000000000
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { IS_PLATFORM } from 'common'
-import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
-import { ButtonTooltip } from 'components/ui/ButtonTooltip'
-import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
-import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
-import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { HelpCircle } from 'lucide-react'
-import Image from 'next/legacy/image'
-import { useRouter } from 'next/router'
-import { useState } from 'react'
-import SVG from 'react-inlinesvg'
-import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
-import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
-import {
- Button,
- cn,
- Popover,
- Popover_Shadcn_,
- PopoverContent_Shadcn_,
- PopoverTrigger_Shadcn_,
-} from 'ui'
-
-import { ASSISTANT_SUGGESTIONS } from './HelpDropdown.constants'
-import { getSupportLinkQueryParams } from './HelpDropdown.utils'
-import { HelpSection } from './HelpSection'
-
-export const HelpDropdown = () => {
- const router = useRouter()
- const { data: project } = useSelectedProjectQuery()
- const { data: org } = useSelectedOrganizationQuery()
- const snap = useAiAssistantStateSnapshot()
- const { openSidebar } = useSidebarManagerSnapshot()
- const { mutate: sendEvent } = useSendEventMutation()
- const [isOpen, setIsOpen] = useState(false)
-
- const projectRef = project?.parent_project_ref ?? (router.query.ref as string | undefined)
- const supportLinkQueryParams = getSupportLinkQueryParams(
- project,
- org,
- router.query.ref as string | undefined
- )
-
- return (
-
-
- {
- if (isOpen) return // Don't send telemetry event if dropdown is already open
- sendEvent({
- action: 'help_button_clicked',
- groups: { project: project?.ref, organization: org?.slug },
- })
- }}
- tooltip={{ content: { side: 'bottom', text: 'Help' } }}
- >
-
-
-
-
- {
- setIsOpen(false)
- openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
- snap.newChat(ASSISTANT_SUGGESTIONS)
- }}
- onSupportClick={() => setIsOpen(false)}
- />
-
-
-
-
Community support
-
- Our Discord community can help with code-related issues. Many questions are answered
- in minutes.
-
-
-
-
-
-
- )
-}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpButton.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpButton.tsx
new file mode 100644
index 0000000000000..c986e14a34c83
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpButton.tsx
@@ -0,0 +1,46 @@
+import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
+import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { HelpCircle } from 'lucide-react'
+import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
+import { cn } from 'ui'
+
+export const HelpButton = () => {
+ const { toggleSidebar, activeSidebar } = useSidebarManagerSnapshot()
+ const { data: project } = useSelectedProjectQuery()
+ const { data: org } = useSelectedOrganizationQuery()
+ const { mutate: sendEvent } = useSendEventMutation()
+
+ const isOpen = activeSidebar?.id === SIDEBAR_KEYS.HELP_PANEL
+
+ return (
+
{
+ toggleSidebar(SIDEBAR_KEYS.HELP_PANEL)
+ // Don't send telemetry event if dropdown is already open
+ if (!isOpen) {
+ sendEvent({
+ action: 'help_button_clicked',
+ groups: { project: project?.ref, organization: org?.slug },
+ })
+ }
+ }}
+ tooltip={{ content: { side: 'bottom', text: 'Help' } }}
+ >
+
+
+ )
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpOptionsList.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpOptionsList.tsx
similarity index 97%
rename from apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpOptionsList.tsx
rename to apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpOptionsList.tsx
index 33171c30f4444..278f35584623b 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpOptionsList.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpOptionsList.tsx
@@ -6,8 +6,8 @@ import { useRouter } from 'next/router'
import SVG from 'react-inlinesvg'
import { AiIconAnimation, ButtonGroup, ButtonGroupItem } from 'ui'
-import type { HelpOptionId } from './HelpDropdown.constants'
-import { HELP_OPTION_IDS } from './HelpDropdown.constants'
+import type { HelpOptionId } from './HelpPanel.constants'
+import { HELP_OPTION_IDS } from './HelpPanel.constants'
const DISCORD_URL = 'https://discord.supabase.com'
const STATUS_URL = 'https://status.supabase.com'
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.constants.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.constants.ts
similarity index 100%
rename from apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.constants.ts
rename to apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.constants.ts
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.tsx
new file mode 100644
index 0000000000000..264450d190d89
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.tsx
@@ -0,0 +1,97 @@
+import { IS_PLATFORM } from 'common'
+import type { SupportFormUrlKeys } from 'components/interfaces/Support/SupportForm.utils'
+import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { X } from 'lucide-react'
+import Image from 'next/image'
+import { useRouter } from 'next/router'
+import SVG from 'react-inlinesvg'
+import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
+import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
+import { Button, cn, Separator } from 'ui'
+import styleHandler from 'ui/src/lib/theme/styleHandler'
+
+import { ASSISTANT_SUGGESTIONS } from './HelpPanel.constants'
+import { HelpSection } from './HelpSection'
+
+export const HelpPanel = ({
+ onClose,
+ projectRef,
+ supportLinkQueryParams,
+}: {
+ onClose: () => void
+ projectRef: string | undefined
+ supportLinkQueryParams: Partial
| undefined
+}) => {
+ const snap = useAiAssistantStateSnapshot()
+ const { openSidebar, closeSidebar } = useSidebarManagerSnapshot()
+ const router = useRouter()
+
+ const __styles = styleHandler('popover')
+
+ return (
+
+
+ Help & Support
+ closeSidebar(SIDEBAR_KEYS.HELP_PANEL)}
+ icon={}
+ tooltip={{ content: { side: 'bottom', text: 'Close' } }}
+ />
+
+
{
+ onClose()
+ openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
+ snap.newChat(ASSISTANT_SUGGESTIONS)
+ }}
+ onSupportClick={onClose}
+ />
+
+
+
+
Community support
+
+ Our Discord community can help with code-related issues. Many questions are answered in
+ minutes.
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.test.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.test.ts
new file mode 100644
index 0000000000000..95e640f0e3846
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+
+import { getSupportLinkQueryParams } from './HelpPanel.utils'
+
+describe('getSupportLinkQueryParams', () => {
+ it('returns { projectRef } when project has parent_project_ref', () => {
+ expect(
+ getSupportLinkQueryParams(
+ { parent_project_ref: 'main-project' },
+ { slug: 'my-org' },
+ 'router-ref'
+ )
+ ).toEqual({ projectRef: 'main-project' })
+ })
+
+ it('returns { projectRef } from routerRef when project has no parent_project_ref', () => {
+ expect(getSupportLinkQueryParams({}, { slug: 'my-org' }, 'router-ref')).toEqual({
+ projectRef: 'router-ref',
+ })
+ })
+
+ it('returns { projectRef } from routerRef when project is undefined', () => {
+ expect(getSupportLinkQueryParams(undefined, { slug: 'my-org' }, 'router-ref')).toEqual({
+ projectRef: 'router-ref',
+ })
+ })
+
+ it('returns { orgSlug } when no projectRef (no project, no routerRef)', () => {
+ expect(getSupportLinkQueryParams(undefined, { slug: 'my-org' }, undefined)).toEqual({
+ orgSlug: 'my-org',
+ })
+ })
+
+ it('returns { orgSlug } when project and routerRef are undefined but org has slug', () => {
+ expect(getSupportLinkQueryParams(undefined, { slug: 'acme' }, undefined)).toEqual({
+ orgSlug: 'acme',
+ })
+ })
+
+ it('returns undefined when project, org and routerRef give no ref', () => {
+ expect(getSupportLinkQueryParams(undefined, undefined, undefined)).toBeUndefined()
+ })
+
+ it('returns undefined when org has no slug and no projectRef', () => {
+ expect(getSupportLinkQueryParams(undefined, {}, undefined)).toBeUndefined()
+ })
+
+ it('returns undefined when org is undefined and no projectRef', () => {
+ expect(getSupportLinkQueryParams(undefined, undefined, undefined)).toBeUndefined()
+ })
+
+ it('prefers parent_project_ref over routerRef when both are present', () => {
+ expect(
+ getSupportLinkQueryParams({ parent_project_ref: 'parent-ref' }, { slug: 'org' }, 'router-ref')
+ ).toEqual({ projectRef: 'parent-ref' })
+ })
+})
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.utils.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.ts
similarity index 100%
rename from apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpDropdown.utils.ts
rename to apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel.utils.ts
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpSection.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpSection.tsx
similarity index 95%
rename from apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpSection.tsx
rename to apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpSection.tsx
index bc6a246880002..7aea2451dc4f3 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpDropdown/HelpSection.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpSection.tsx
@@ -1,8 +1,8 @@
import type { SupportFormUrlKeys } from 'components/interfaces/Support/SupportForm.utils'
import { cn } from 'ui'
-import type { HelpOptionId } from './HelpDropdown.constants'
import { HelpOptionsList } from './HelpOptionsList'
+import type { HelpOptionId } from './HelpPanel.constants'
type HelpSectionProps = {
excludeIds?: HelpOptionId[]
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx
index 08f9f317bf2d3..ce5d2e78aebc4 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx
@@ -26,7 +26,7 @@ import { CommandMenuTriggerInput } from 'ui-patterns'
import { BreadcrumbsView } from './BreadcrumbsView'
import { FeedbackDropdown } from './FeedbackDropdown/FeedbackDropdown'
-import { HelpDropdown } from './HelpDropdown/HelpDropdown'
+import { HelpButton } from './HelpPanel/HelpButton'
import { HomeIcon } from './HomeIcon'
import { LocalVersionPopover } from './LocalVersionPopover'
import { MergeRequestButton } from './MergeRequestButton'
@@ -231,7 +231,7 @@ export const LayoutHeader = ({
'[&_.command-shortcut>div]:text-foreground-lighter'
)}
/>
-
+
{!!projectRef && (
@@ -257,7 +257,7 @@ export const LayoutHeader = ({
[&_.command-shortcut>div]:text-foreground-lighter
"
/>
-
+
{!!projectRef && (
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx
index f653c316d499d..74aa05af224b7 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx
@@ -1,22 +1,37 @@
-import { useRouter } from 'next/router'
-import { parseAsString, useQueryState } from 'nuqs'
-import { PropsWithChildren, useEffect } from 'react'
-
import { LOCAL_STORAGE_KEYS } from 'common'
-import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel'
-import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant'
-import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import useLatest from 'hooks/misc/useLatest'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import dynamic from 'next/dynamic'
+import { useRouter } from 'next/router'
+import { parseAsString, useQueryState } from 'nuqs'
+import { useEffect, type PropsWithChildren } from 'react'
import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
+import { getSupportLinkQueryParams } from '../LayoutHeader/HelpPanel/HelpPanel.utils'
+
+const AdvisorPanel = dynamic(() =>
+ import('components/ui/AdvisorPanel/AdvisorPanel').then((m) => m.AdvisorPanel)
+)
+const AIAssistant = dynamic(() =>
+ import('components/ui/AIAssistantPanel/AIAssistant').then((m) => m.AIAssistant)
+)
+const EditorPanel = dynamic(() =>
+ import('components/ui/EditorPanel/EditorPanel').then((m) => m.EditorPanel)
+)
+const HelpPanel = dynamic(() =>
+ import('components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel').then(
+ (m) => m.HelpPanel
+ )
+)
+
export const SIDEBAR_KEYS = {
AI_ASSISTANT: 'ai-assistant',
EDITOR_PANEL: 'editor-panel',
ADVISOR_PANEL: 'advisor-panel',
+ HELP_PANEL: 'help-panel',
} as const
export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
@@ -24,7 +39,7 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
- const { openSidebar, activeSidebar } = useSidebarManagerSnapshot()
+ const { openSidebar, closeSidebar, activeSidebar } = useSidebarManagerSnapshot()
const [sidebarURLParam, setSidebarUrlParam] = useQueryState('sidebar', parseAsString)
const [sidebarLocalStorage, setSidebarLocalStorage, { isSuccess: isLoadedLocalStorage }] =
@@ -36,6 +51,23 @@ export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i', !!project)
useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e', !!project)
useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => , {}, undefined, true)
+ useRegisterSidebar(
+ SIDEBAR_KEYS.HELP_PANEL,
+ () => (
+ closeSidebar(SIDEBAR_KEYS.HELP_PANEL)}
+ projectRef={project?.ref}
+ supportLinkQueryParams={getSupportLinkQueryParams(
+ project,
+ org,
+ router.query.ref as string | undefined
+ )}
+ />
+ ),
+ {},
+ undefined,
+ true
+ )
useEffect(() => {
if (!!project) {
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx
index 889e07bc12443..8872548186be2 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx
@@ -1,10 +1,11 @@
import { act, screen, waitFor } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
import { sidebarManagerState } from 'state/sidebar-manager-state'
import { render } from 'tests/helpers'
import { routerMock } from 'tests/lib/route-mock'
import { ResizablePanel, ResizablePanelGroup } from 'ui'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { MobileSheetProvider } from '../NavigationBar/MobileSheetContext'
import { LayoutSidebar } from './index'
import { LayoutSidebarProvider, SIDEBAR_KEYS } from './LayoutSidebarProvider'
@@ -109,7 +110,9 @@ describe('LayoutSidebar', () => {
-
+
+
+
)
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx
index 3e3161f0ce447..b8e61d94b516d 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx
@@ -1,5 +1,10 @@
+import { useBreakpoint } from 'common'
+import { useEffect } from 'react'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
-import { ResizableHandle, ResizablePanel, cn } from 'ui'
+import { cn, ResizableHandle, ResizablePanel } from 'ui'
+import { MobileSheetNav } from 'ui-patterns'
+
+import { useMobileSheet } from '../NavigationBar/MobileSheetContext'
// Having these params as props as otherwise it's quite hard to visually check the sizes in DefaultLayout
// as react resizeable panels requires all these values to be valid to render correctly
@@ -14,10 +19,36 @@ export const LayoutSidebar = ({
maxSize = '50',
defaultSize = '30',
}: LayoutSidebarProps) => {
- const { activeSidebar } = useSidebarManagerSnapshot()
+ const { activeSidebar, closeActive } = useSidebarManagerSnapshot()
+ const isMobile = useBreakpoint('md')
+ const { content: mobileSheetContent, setContent: setMobileSheetContent } = useMobileSheet()
+
+ // On mobile the sidebar content is rendered in MobileSheetNav
+ useEffect(() => {
+ if (isMobile && activeSidebar?.component) {
+ setMobileSheetContent(activeSidebar.id)
+ } else {
+ setMobileSheetContent(null)
+ }
+ }, [isMobile, activeSidebar, setMobileSheetContent])
if (!activeSidebar?.component) return null
+ if (isMobile)
+ return (
+ {
+ if (!open) {
+ setMobileSheetContent(null)
+ closeActive()
+ }
+ }}
+ >
+ {activeSidebar?.component?.()}
+
+ )
+
return (
<>
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx
index 04b87035fd372..15873138927b0 100644
--- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx
@@ -1,14 +1,12 @@
+import { useParams } from 'common'
+import { SidebarContent } from 'components/interfaces/Sidebar'
+import { IS_PLATFORM } from 'lib/constants'
import { Menu, Search } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
-
-import { useParams } from 'common'
-import { SidebarContent } from 'components/interfaces/Sidebar'
-import { IS_PLATFORM } from 'lib/constants'
import { Button, cn } from 'ui'
-import { CommandMenuTrigger } from 'ui-patterns'
-import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav'
+import { CommandMenuTrigger, MobileSheetNav } from 'ui-patterns'
export const ICON_SIZE = 20
export const ICON_STROKE_WIDTH = 1.5
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx
new file mode 100644
index 0000000000000..72a6f76ff5059
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx
@@ -0,0 +1,33 @@
+import type { PropsWithChildren } from 'react'
+import { createContext, useCallback, useContext, useState } from 'react'
+
+export type MobileSheetContentType = null | string
+
+type MobileSheetContextValue = {
+ content: MobileSheetContentType
+ setContent: (content: MobileSheetContentType) => void
+}
+
+const MobileSheetContext = createContext(null)
+
+export function MobileSheetProvider({ children }: PropsWithChildren) {
+ const [content, setContentState] = useState(null)
+
+ const setContent = useCallback((next: MobileSheetContentType) => {
+ setContentState(next)
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useMobileSheet(): MobileSheetContextValue {
+ const ctx = useContext(MobileSheetContext)
+ if (!ctx) {
+ throw new Error('useMobileSheet must be used within MobileSheetProvider')
+ }
+ return ctx
+}
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx
new file mode 100644
index 0000000000000..423a81f812a2b
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx
@@ -0,0 +1,23 @@
+import { sidebarManagerState, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
+import { MobileSheetNav } from 'ui-patterns'
+
+import { useMobileSheet } from './MobileSheetContext'
+
+export function MobileSheetNavLayout() {
+ const { content: mobileSheetContent, setContent: setMobileSheetContent } = useMobileSheet()
+ const { activeSidebar } = useSidebarManagerSnapshot()
+
+ return (
+ {
+ if (!open) {
+ setMobileSheetContent(null)
+ sidebarManagerState.closeActive()
+ }
+ }}
+ >
+ {activeSidebar?.component?.() ?? null}
+
+ )
+}
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index 7286edec5fd23..4eb26581df46f 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -2320,7 +2320,7 @@ export interface SidebarOpenedEvent {
/**
* The sidebar panel that was opened, e.g. ai-assistant, editor-panel, advisor-panel
*/
- sidebar: 'ai-assistant' | 'editor-panel' | 'advisor-panel'
+ sidebar: 'ai-assistant' | 'editor-panel' | 'advisor-panel' | 'help-panel'
}
groups: TelemetryGroups
}
diff --git a/packages/ui-patterns/index.tsx b/packages/ui-patterns/index.tsx
index 1be06287121d5..f7ffdde0b60fd 100644
--- a/packages/ui-patterns/index.tsx
+++ b/packages/ui-patterns/index.tsx
@@ -16,6 +16,7 @@ export * from './src/FilterBar'
export * from './src/GlassPanel'
export * from './src/InnerSideMenu'
export * from './src/McpUrlBuilder'
+export * from './src/MobileSheetNav'
export * from './src/PageContainer'
export * from './src/PageHeader'
export * from './src/PageSection'
diff --git a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx
index 6e06fc0621dd1..7749316d350dd 100644
--- a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx
+++ b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx
@@ -1,9 +1,6 @@
'use client'
-import { useRouter } from 'next/router'
-import { useEffect } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
-import { useWindowSize } from 'react-use'
import { CommandEmpty_Shadcn_, Sheet, SheetContent } from 'ui'
import { cn } from 'ui/src/lib/utils'
@@ -11,19 +8,8 @@ const MobileSheetNav: React.FC<{
children: React.ReactNode
open?: boolean
onOpenChange(open: boolean): void
-}> = ({ children, open = false, onOpenChange }) => {
- const router = useRouter()
- const { width } = useWindowSize()
-
- const pathWithoutQuery = router?.asPath?.split('?')?.[0]
- useEffect(() => {
- onOpenChange(false)
- }, [pathWithoutQuery])
-
- useEffect(() => {
- onOpenChange(false)
- }, [width])
-
+ className?: string
+}> = ({ children, open = false, onOpenChange, className }) => {
return (
}>{children}
@@ -39,4 +28,5 @@ const MobileSheetNav: React.FC<{
)
}
+export { MobileSheetNav }
export default MobileSheetNav
diff --git a/packages/ui-patterns/src/MobileSheetNav/index.ts b/packages/ui-patterns/src/MobileSheetNav/index.ts
index fd58868d48141..06b0a8256915b 100644
--- a/packages/ui-patterns/src/MobileSheetNav/index.ts
+++ b/packages/ui-patterns/src/MobileSheetNav/index.ts
@@ -1 +1 @@
-export * from './MobileSheetNav'
+export { default as MobileSheetNav } from './MobileSheetNav'
\ No newline at end of file
From affdf865e0d95f92a73bee16eb9a8edd8f981343 Mon Sep 17 00:00:00 2001
From: Francesco Sansalvadore
Date: Fri, 27 Feb 2026 16:14:57 +0100
Subject: [PATCH 5/6] fix: mobile sheet nav close (#43239)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mobile sheet nav doesn't close on route change and viewport resizing
after #43184.
This PR fixes that and also makes it optional to close on nav and
resizing, because the sidepanels need them _not_ to resize, while mobile
navigation menu does.
Also added some tests. 🪄
---
.../ProjectLayout/LayoutSidebar/index.tsx | 2 +
.../NavigationBar/MobileSheetNavLayout.tsx | 23 --
.../MobileSheetNav/MobileSheetNav.test.tsx | 199 ++++++++++++++++++
.../src/MobileSheetNav/MobileSheetNav.tsx | 30 ++-
4 files changed, 230 insertions(+), 24 deletions(-)
delete mode 100644 apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx
create mode 100644 packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.test.tsx
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx
index b8e61d94b516d..ac76bf481647d 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx
@@ -37,6 +37,8 @@ export const LayoutSidebar = ({
if (isMobile)
return (
{
if (!open) {
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx
deleted file mode 100644
index 423a81f812a2b..0000000000000
--- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetNavLayout.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { sidebarManagerState, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
-import { MobileSheetNav } from 'ui-patterns'
-
-import { useMobileSheet } from './MobileSheetContext'
-
-export function MobileSheetNavLayout() {
- const { content: mobileSheetContent, setContent: setMobileSheetContent } = useMobileSheet()
- const { activeSidebar } = useSidebarManagerSnapshot()
-
- return (
- {
- if (!open) {
- setMobileSheetContent(null)
- sidebarManagerState.closeActive()
- }
- }}
- >
- {activeSidebar?.component?.() ?? null}
-
- )
-}
diff --git a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.test.tsx b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.test.tsx
new file mode 100644
index 0000000000000..65137313efda0
--- /dev/null
+++ b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.test.tsx
@@ -0,0 +1,199 @@
+import { act, render, screen, waitFor } from '@testing-library/react'
+import { useEffect, useState } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { MobileSheetNav } from './MobileSheetNav'
+
+const mockRouter = vi.hoisted(() => ({ asPath: '/initial' }))
+const mockWindowSize = vi.hoisted(() => ({ width: 400 }))
+
+vi.mock('next/router', () => ({
+ useRouter: () => mockRouter,
+}))
+
+vi.mock('react-use', () => ({
+ useWindowSize: () => mockWindowSize,
+}))
+
+function MobileSheetNavWithState({
+ shouldCloseOnRouteChange = true,
+ shouldCloseOnViewportResize = true,
+}: {
+ shouldCloseOnRouteChange?: boolean
+ shouldCloseOnViewportResize?: boolean
+}) {
+ const [open, setOpen] = useState(false)
+ useEffect(() => {
+ setOpen(true)
+ }, [])
+ return (
+ <>
+ {String(open)}
+
+ Nav content
+
+ >
+ )
+}
+
+describe('MobileSheetNav', () => {
+ const defaultProps = {
+ onOpenChange: vi.fn(),
+ children: Nav content
,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockRouter.asPath = '/initial'
+ mockWindowSize.width = 400
+ })
+
+ describe('shouldCloseOnRouteChange', () => {
+ it('calls onOpenChange(false) when route changes and shouldCloseOnRouteChange is true (default)', () => {
+ const onOpenChange = vi.fn()
+ const { rerender } = render()
+
+ onOpenChange.mockClear()
+ mockRouter.asPath = '/other-page'
+ act(() => {
+ rerender()
+ })
+
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('effectively closes the sheet on route change when shouldCloseOnRouteChange is true', async () => {
+ const { rerender } = render()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sheet-open')).toHaveTextContent('true')
+ })
+
+ mockRouter.asPath = '/other-page'
+ act(() => {
+ rerender()
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sheet-open')).toHaveTextContent('false')
+ })
+ })
+
+ it('effectively does NOT close the sheet on route change when shouldCloseOnRouteChange is false', async () => {
+ const { rerender } = render()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sheet-open')).toHaveTextContent('true')
+ })
+
+ mockRouter.asPath = '/other-page'
+ act(() => {
+ rerender()
+ })
+
+ expect(screen.getByTestId('sheet-open')).toHaveTextContent('true')
+ })
+
+ it('does not call onOpenChange when route changes if shouldCloseOnRouteChange is false', () => {
+ const onOpenChange = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ onOpenChange.mockClear()
+ mockRouter.asPath = '/other-page'
+ act(() => {
+ rerender(
+
+ )
+ })
+
+ expect(onOpenChange).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('shouldCloseOnViewportResize', () => {
+ it('calls onOpenChange(false) when width changes and shouldCloseOnViewportResize is true (default)', () => {
+ const onOpenChange = vi.fn()
+ const { rerender } = render()
+
+ onOpenChange.mockClear()
+ mockWindowSize.width = 800
+ act(() => {
+ rerender()
+ })
+
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('effectively closes the sheet on viewport resize when shouldCloseOnViewportResize is true', async () => {
+ const { rerender } = render()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sheet-open')).toHaveTextContent('true')
+ })
+
+ mockWindowSize.width = 800
+ act(() => {
+ rerender()
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sheet-open')).toHaveTextContent('false')
+ })
+ })
+
+ it('effectively does NOT close the sheet on viewport resize when shouldCloseOnViewportResize is false', async () => {
+ const { rerender } = render()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('sheet-open')).toHaveTextContent('true')
+ })
+
+ mockWindowSize.width = 800
+ act(() => {
+ rerender()
+ })
+
+ expect(screen.getByTestId('sheet-open')).toHaveTextContent('true')
+ })
+
+ it('does not call onOpenChange when width changes if shouldCloseOnViewportResize is false', () => {
+ const onOpenChange = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ onOpenChange.mockClear()
+ mockWindowSize.width = 800
+ act(() => {
+ rerender(
+
+ )
+ })
+
+ expect(onOpenChange).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx
index 7749316d350dd..064035ac60739 100644
--- a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx
+++ b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx
@@ -1,6 +1,9 @@
'use client'
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
+import { useWindowSize } from 'react-use'
import { CommandEmpty_Shadcn_, Sheet, SheetContent } from 'ui'
import { cn } from 'ui/src/lib/utils'
@@ -9,7 +12,32 @@ const MobileSheetNav: React.FC<{
open?: boolean
onOpenChange(open: boolean): void
className?: string
-}> = ({ children, open = false, onOpenChange, className }) => {
+ shouldCloseOnRouteChange?: boolean
+ shouldCloseOnViewportResize?: boolean
+}> = ({
+ children,
+ open = false,
+ onOpenChange,
+ className,
+ shouldCloseOnRouteChange = true,
+ shouldCloseOnViewportResize = true,
+}) => {
+ const router = useRouter()
+ const { width } = useWindowSize()
+
+ const pathWithoutQuery = router?.asPath?.split('?')?.[0]
+ useEffect(() => {
+ if (shouldCloseOnRouteChange) {
+ onOpenChange(false)
+ }
+ }, [pathWithoutQuery])
+
+ useEffect(() => {
+ if (shouldCloseOnViewportResize) {
+ onOpenChange(false)
+ }
+ }, [width])
+
return (
Date: Fri, 27 Feb 2026 10:42:07 -0500
Subject: [PATCH 6/6] refactor: move storage utils out of valtio + write tests
(#43225)
Refactor
## What is the current behavior?
Many utility functions are in the Valtio object which is overly large
and complex.
## What is the new behavior?
Some utility functions are moved out and tested. No behaviour has been
changed, they've just been moved with any necessary changes to
arguments.
---
.../StorageExplorer.utils.test.ts | 277 ++++++++++++++++++
.../StorageExplorer/StorageExplorer.utils.tsx | 101 +++++++
.../Storage/StorageExplorer/useCopyUrl.tsx | 8 +-
.../StorageExplorer/useFetchFileUrlQuery.tsx | 5 +-
apps/studio/state/storage-explorer.tsx | 101 +------
5 files changed, 397 insertions(+), 95 deletions(-)
create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts
new file mode 100644
index 0000000000000..478f04475e47b
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts
@@ -0,0 +1,277 @@
+import { toast } from 'sonner'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ STORAGE_ROW_STATUS,
+ STORAGE_ROW_TYPES,
+} from '@/components/interfaces/Storage/Storage.constants'
+import type { StorageItem } from '@/components/interfaces/Storage/Storage.types'
+import {
+ getPathAlongFoldersToIndex,
+ getPathAlongOpenedFolders,
+ sanitizeNameForDuplicateInColumn,
+ validateFolderName,
+} from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils'
+
+function makeBucket(name: string) {
+ return {
+ id: name,
+ name,
+ owner: 'owner',
+ public: false,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ }
+}
+
+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,
+ }
+}
+
+describe('validateFolderName', () => {
+ describe('valid names', () => {
+ it('accepts plain alphanumeric names', () => {
+ expect(validateFolderName('myfolder')).toBeNull()
+ expect(validateFolderName('MyFolder123')).toBeNull()
+ })
+
+ it('accepts names with underscores and hyphens', () => {
+ expect(validateFolderName('my_folder')).toBeNull()
+ expect(validateFolderName('my-folder')).toBeNull()
+ })
+
+ it('accepts names with dots', () => {
+ expect(validateFolderName('my.folder')).toBeNull()
+ })
+
+ it('accepts names with spaces', () => {
+ expect(validateFolderName('my folder')).toBeNull()
+ })
+
+ it('accepts names with allowed special characters', () => {
+ expect(validateFolderName('folder!')).toBeNull()
+ expect(validateFolderName("folder'")).toBeNull()
+ expect(validateFolderName('folder(1)')).toBeNull()
+ expect(validateFolderName('folder*')).toBeNull()
+ expect(validateFolderName('folder&name')).toBeNull()
+ expect(validateFolderName('folder$name')).toBeNull()
+ expect(validateFolderName('folder@name')).toBeNull()
+ expect(validateFolderName('folder=name')).toBeNull()
+ expect(validateFolderName('folder;name')).toBeNull()
+ expect(validateFolderName('folder:name')).toBeNull()
+ expect(validateFolderName('folder+name')).toBeNull()
+ expect(validateFolderName('folder,name')).toBeNull()
+ expect(validateFolderName('folder?name')).toBeNull()
+ })
+
+ it('accepts names with forward slashes', () => {
+ expect(validateFolderName('parent/child')).toBeNull()
+ })
+
+ it('accepts an empty string', () => {
+ expect(validateFolderName('')).toBeNull()
+ })
+ })
+
+ describe('invalid names', () => {
+ it('rejects a name containing #', () => {
+ const result = validateFolderName('my#folder')
+ expect(result).toBe('Folder name cannot contain the "#" character')
+ })
+
+ it('rejects a name containing %', () => {
+ const result = validateFolderName('my%folder')
+ expect(result).toBe('Folder name cannot contain the "%" character')
+ })
+
+ it('rejects a name containing ^', () => {
+ const result = validateFolderName('my^folder')
+ expect(result).toBe('Folder name cannot contain the "^" character')
+ })
+
+ it('rejects a name containing [', () => {
+ const result = validateFolderName('my[folder')
+ expect(result).toBe('Folder name cannot contain the "[" character')
+ })
+ })
+})
+
+describe('getPathAlongOpenedFolders', () => {
+ const selectedBucket = makeBucket('my-bucket')
+
+ it('returns only the bucket name when there are no opened folders and includeBucket=true', () => {
+ expect(getPathAlongOpenedFolders({ openedFolders: [], selectedBucket })).toBe('my-bucket')
+ })
+
+ it('returns bucket/folder when one folder is open and includeBucket=true', () => {
+ expect(
+ getPathAlongOpenedFolders({ openedFolders: [makeFolder('images')], selectedBucket })
+ ).toBe('my-bucket/images')
+ })
+
+ it('returns the full path when multiple folders are open and includeBucket=true', () => {
+ const openedFolders = [makeFolder('images'), makeFolder('2024'), makeFolder('january')]
+ expect(getPathAlongOpenedFolders({ openedFolders, selectedBucket })).toBe(
+ 'my-bucket/images/2024/january'
+ )
+ })
+
+ it('returns an empty string when there are no opened folders and includeBucket=false', () => {
+ expect(getPathAlongOpenedFolders({ openedFolders: [], selectedBucket }, false)).toBe('')
+ })
+
+ it('returns the folder path without the bucket when includeBucket=false', () => {
+ const openedFolders = [makeFolder('images'), makeFolder('2024')]
+ expect(getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false)).toBe('images/2024')
+ })
+})
+
+describe('getPathAlongFoldersToIndex', () => {
+ const openedFolders = [makeFolder('images'), makeFolder('2024'), makeFolder('january')]
+
+ it('returns an empty string for index 0', () => {
+ expect(getPathAlongFoldersToIndex({ openedFolders }, 0)).toBe('')
+ })
+
+ it('returns the first folder name for index 1', () => {
+ expect(getPathAlongFoldersToIndex({ openedFolders }, 1)).toBe('images')
+ })
+
+ it('returns folders joined up to (not including) the given index', () => {
+ expect(getPathAlongFoldersToIndex({ openedFolders }, 2)).toBe('images/2024')
+ expect(getPathAlongFoldersToIndex({ openedFolders }, 3)).toBe('images/2024/january')
+ })
+
+ it('returns an empty string for an empty openedFolders array', () => {
+ expect(getPathAlongFoldersToIndex({ openedFolders: [] }, 5)).toBe('')
+ })
+})
+
+vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
+
+describe('sanitizeNameForDuplicateInColumn', () => {
+ // Reset mock call counts between tests
+ beforeEach(() => vi.mocked(toast.error).mockClear())
+
+ // Build a state with one column per array of item overrides.
+ // e.g. makeState([['a.txt'], ['b.txt', 'c.txt']]) → two columns
+ function makeState(columns: Array>>) {
+ return {
+ columns: columns.map((columnItems, i) => ({
+ id: null,
+ name: `col-${i}`,
+ status: STORAGE_ROW_STATUS.READY,
+ items: columnItems.map((overrides) => ({
+ id: 'file-id',
+ name: 'file.txt',
+ type: STORAGE_ROW_TYPES.FILE,
+ status: STORAGE_ROW_STATUS.READY,
+ metadata: null,
+ isCorrupted: false,
+ created_at: null,
+ updated_at: null,
+ last_accessed_at: null,
+ ...overrides,
+ })),
+ })),
+ }
+ }
+
+ it('returns the original name when there is no conflict', () => {
+ const state = makeState([[{ name: 'other.txt' }]])
+ expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt' })).toBe('file.txt')
+ })
+
+ it('is case-insensitive when detecting duplicates', () => {
+ const state = makeState([[{ name: 'FILE.TXT' }]])
+ expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt' })).toBeNull()
+ expect(toast.error).toHaveBeenCalled()
+ })
+
+ it('skips items that are currently being edited', () => {
+ const state = makeState([[{ name: 'file.txt', status: STORAGE_ROW_STATUS.EDITING }]])
+ expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt' })).toBe('file.txt')
+ })
+
+ describe('columnIndex', () => {
+ // Two-column state: col 0 has 'file.txt', col 1 has 'other.txt'
+ const state = makeState([[{ name: 'file.txt' }], [{ name: 'other.txt' }]])
+
+ it('defaults to the last column when columnIndex is omitted', () => {
+ // col 1 has 'other.txt', not 'file.txt' → no conflict
+ expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt' })).toBe('file.txt')
+ })
+
+ it('uses the explicitly provided columnIndex', () => {
+ // col 0 has 'file.txt' → conflict
+ expect(
+ sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', columnIndex: 0 })
+ ).toBeNull()
+ expect(toast.error).toHaveBeenCalled()
+ })
+
+ it('only checks the specified column, ignoring conflicts in other columns', () => {
+ // col 1 has 'other.txt' but not 'file.txt' → no conflict at columnIndex 1
+ expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', columnIndex: 1 })).toBe(
+ 'file.txt'
+ )
+ })
+
+ it('detects a conflict in the first column when columnIndex is 0', () => {
+ // col 0 has 'file.txt' → conflict
+ expect(sanitizeNameForDuplicateInColumn(state, { name: 'other.txt', columnIndex: 0 })).toBe(
+ 'other.txt'
+ ) // 'other.txt' is not in col 0
+ expect(
+ sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', columnIndex: 0 })
+ ).toBeNull()
+ })
+ })
+
+ describe('autofix: false (default)', () => {
+ it('shows an error toast and returns null on conflict', () => {
+ const state = makeState([[{ name: 'file.txt' }]])
+ const result = sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', autofix: false })
+ expect(result).toBeNull()
+ expect(toast.error).toHaveBeenCalledWith(
+ 'The name file.txt already exists in the current directory. Please use a different name.'
+ )
+ })
+ })
+
+ describe('autofix: true', () => {
+ it('appends (1) to a file name with no prior duplicates', () => {
+ const state = makeState([[{ name: 'file.txt' }]])
+ expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', autofix: true })).toBe(
+ 'file (1).txt'
+ )
+ })
+
+ it('appends (2) when one auto-named duplicate already exists', () => {
+ const state = makeState([[{ name: 'file.txt' }, { name: 'file (1).txt' }]])
+ expect(sanitizeNameForDuplicateInColumn(state, { name: 'file.txt', autofix: true })).toBe(
+ 'file (2).txt'
+ )
+ })
+
+ it('treats the whole name as the extension when there is no dot (existing behaviour)', () => {
+ // NOTE: the function splits on '.' and always treats the last segment as the
+ // extension, so a dotless name produces " (1).myfile" rather than "myfile (1)".
+ // This is a known quirk of the implementation — not a regression.
+ const state = makeState([[{ name: 'myfile' }]])
+ expect(sanitizeNameForDuplicateInColumn(state, { name: 'myfile', autofix: true })).toBe(
+ ' (1).myfile'
+ )
+ })
+ })
+})
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx
index bd1af77e8f1ae..bc8dbe73db08f 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx
@@ -2,8 +2,10 @@ import { toast } from 'sonner'
import { StorageObject } from 'data/storage/bucket-objects-list-mutation'
import { copyToClipboard } from 'ui'
+import { inverseValidObjectKeyRegex, validObjectKeyRegex } from '../CreateBucketModal.utils'
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
import { StorageItem, StorageItemMetadata } from '../Storage.types'
+import type { StorageExplorerState } from '@/state/storage-explorer'
type UploadProgress = {
percentage: number
@@ -16,6 +18,105 @@ type UploadProgress = {
const CORRUPTED_THRESHOLD_MS = 15 * 60 * 1000 // 15 minutes
export const EMPTY_FOLDER_PLACEHOLDER_FILE_NAME = '.emptyFolderPlaceholder'
+/**
+ * Returns the path to the current folder, optionally prefixed with the bucket name.
+ */
+export function getPathAlongOpenedFolders(
+ state: Pick,
+ includeBucket = true
+): string {
+ if (includeBucket) {
+ return state.openedFolders.length > 0
+ ? `${state.selectedBucket.name}/${state.openedFolders.map((folder) => folder.name).join('/')}`
+ : state.selectedBucket.name
+ }
+ return state.openedFolders.map((folder) => folder.name).join('/')
+}
+
+/**
+ * Returns the path to the folder at the given index in the openedFolders array,
+ * joining all folders from the root up to (but not including) the given index.
+ */
+export function getPathAlongFoldersToIndex(
+ state: Pick,
+ index: number
+): string {
+ return state.openedFolders
+ .slice(0, index)
+ .map((folder) => folder.name)
+ .join('/')
+}
+
+/**
+ * Returns an error message string if the folder name contains invalid characters,
+ * or null if the name is valid.
+ */
+export function validateFolderName(name: string): string | null {
+ if (!validObjectKeyRegex.test(name)) {
+ const [match] = name.match(inverseValidObjectKeyRegex) ?? []
+ return !!match
+ ? `Folder name cannot contain the "${match}" character`
+ : 'Folder name contains an invalid special character'
+ }
+ return null
+}
+
+/**
+ * Checks whether `name` already exists in the column (case-insensitive).
+ * - When `autofix` is false and a duplicate is found, shows an error toast and returns null.
+ * - When `autofix` is true and a duplicate is found, appends a numeric suffix and returns the new name.
+ * - Returns the original name when there is no conflict.
+ *
+ * When `columnIndex` is omitted it defaults to the last column.
+ */
+export function sanitizeNameForDuplicateInColumn(
+ state: Pick,
+ {
+ name,
+ columnIndex,
+ autofix = false,
+ }: {
+ name: string
+ columnIndex?: number
+ autofix?: boolean
+ }
+): string | null {
+ const columnIndex_ = columnIndex !== undefined ? columnIndex : state.columns.length - 1
+ const currentColumn = state.columns[columnIndex_]
+ const currentColumnItems = currentColumn.items.filter(
+ (item) => item.status !== STORAGE_ROW_STATUS.EDITING
+ )
+ // [Joshen] JFYI storage does support folders of the same name with different casing
+ // but its an issue with the List V1 endpoint that's causing an issue with fetching contents
+ // for folders of the same name with different casing
+ // We should remove this check once all projects are on the List V2 endpoint
+ const hasSameNameInColumn =
+ currentColumnItems.filter((item) => item.name.toLowerCase() === name.toLowerCase()).length > 0
+
+ if (hasSameNameInColumn) {
+ if (autofix) {
+ const fileNameSegments = name.split('.')
+ const fileName = fileNameSegments.slice(0, fileNameSegments.length - 1).join('.')
+ const fileExt = fileNameSegments[fileNameSegments.length - 1]
+
+ const dupeNameRegex = new RegExp(`${fileName} \\([-0-9]+\\)${fileExt ? '.' + fileExt : ''}$`)
+ const itemsWithSameNameInColumn = currentColumnItems.filter((item) =>
+ item.name.match(dupeNameRegex)
+ )
+
+ const updatedFileName = fileName + ` (${itemsWithSameNameInColumn.length + 1})`
+ return fileExt ? `${updatedFileName}.${fileExt}` : updatedFileName
+ } else {
+ toast.error(
+ `The name ${name} already exists in the current directory. Please use a different name.`
+ )
+ return null
+ }
+ }
+
+ return name
+}
+
export const copyPathToFolder = (
openedFolders: StorageItem[],
item: StorageItem & { columnIndex: number }
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx
index 44a9f3b812531..ed94cfbddcc7d 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx
@@ -6,11 +6,11 @@ import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import { copyToClipboard } from 'ui'
import { URL_EXPIRY_DURATION } from '../Storage.constants'
+import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
import { fetchFileUrl } from './useFetchFileUrlQuery'
export const useCopyUrl = () => {
- const { projectRef, selectedBucket, getPathAlongOpenedFolders } =
- useStorageExplorerStateSnapshot()
+ const { projectRef, selectedBucket, openedFolders } = useStorageExplorerStateSnapshot()
const { data: customDomainData } = useCustomDomainsQuery({ projectRef: projectRef })
const { data: settings } = useProjectSettingsV2Query({ projectRef: projectRef })
@@ -20,7 +20,7 @@ export const useCopyUrl = () => {
const getFileUrl = useCallback(
(fileName: string, expiresIn?: URL_EXPIRY_DURATION) => {
- const pathToFile = getPathAlongOpenedFolders(false)
+ const pathToFile = getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false)
const formattedPathToFile = [pathToFile, fileName].join('/')
return fetchFileUrl(
@@ -31,7 +31,7 @@ export const useCopyUrl = () => {
expiresIn
)
},
- [projectRef, selectedBucket.id, selectedBucket.public, getPathAlongOpenedFolders]
+ [projectRef, selectedBucket, openedFolders]
)
const onCopyUrl = useCallback(
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx
index 74579a90e6280..289303c4b9593 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx
@@ -5,6 +5,7 @@ import { Bucket } from 'data/storage/buckets-query'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { StorageItem } from '../Storage.types'
+import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
const DEFAULT_EXPIRY = 7 * 24 * 60 * 60 // in seconds, default to 1 week
@@ -43,8 +44,8 @@ export const useFetchFileUrlQuery = (
{ file, projectRef, bucket }: UseFileUrlQueryVariables,
{ ...options }: UseCustomQueryOptions = {}
) => {
- const { getPathAlongOpenedFolders } = useStorageExplorerStateSnapshot()
- const pathToFile = getPathAlongOpenedFolders(false)
+ const { openedFolders, selectedBucket } = useStorageExplorerStateSnapshot()
+ const pathToFile = getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false)
const formattedPathToFile = [pathToFile, file?.name].join('/')
return useQuery({
diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx
index fa1a9590e69e0..e4fcede7007a1 100644
--- a/apps/studio/state/storage-explorer.tsx
+++ b/apps/studio/state/storage-explorer.tsx
@@ -8,10 +8,6 @@ import * as tus from 'tus-js-client'
import { Button, SONNER_DEFAULT_DURATION, SonnerProgress } from 'ui'
import { proxy, useSnapshot } from 'valtio'
-import {
- inverseValidObjectKeyRegex,
- validObjectKeyRegex,
-} from '@/components/interfaces/Storage/CreateBucketModal.utils'
import {
STORAGE_BUCKET_SORT,
STORAGE_ROW_STATUS,
@@ -32,6 +28,10 @@ import {
formatFolderItems,
formatTime,
getFilesDataTransferItems,
+ getPathAlongFoldersToIndex,
+ getPathAlongOpenedFolders,
+ sanitizeNameForDuplicateInColumn,
+ validateFolderName,
} from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils'
import { convertFromBytes } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { InlineLink } from '@/components/ui/InlineLink'
@@ -73,7 +73,7 @@ const DEFAULT_PREFERENCES = {
}
const STORAGE_PROGRESS_INFO_TEXT = "Do not close the browser until it's completed"
-let abortController: any
+let abortController: AbortController
if (typeof window !== 'undefined') {
abortController = new AbortController()
}
@@ -100,7 +100,6 @@ function createStorageExplorerState({
resumableUploadUrl,
uploadProgresses: [] as UploadProgress[],
- // abortController,
abortApiCalls: () => {
if (abortController) {
abortController.abort()
@@ -133,7 +132,6 @@ function createStorageExplorerState({
popOpenedFolders: () => {
state.openedFolders = state.openedFolders.slice(0, state.openedFolders.length - 1)
},
-
popOpenedFoldersAtIndex: (index: number) => {
state.openedFolders = state.openedFolders.slice(0, index + 1)
},
@@ -241,33 +239,6 @@ function createStorageExplorerState({
// ======== Folders CRUD ========
- getPathAlongOpenedFolders: (includeBucket = true) => {
- if (includeBucket) {
- return state.openedFolders.length > 0
- ? `${state.selectedBucket.name}/${state.openedFolders.map((folder) => folder.name).join('/')}`
- : state.selectedBucket.name
- }
- return state.openedFolders.map((folder) => folder.name).join('/')
- },
-
- getPathAlongFoldersToIndex: (index: number) => {
- return state.openedFolders
- .slice(0, index)
- .map((folder) => folder.name)
- .join('/')
- },
-
- validateFolderName: (name: string) => {
- if (!validObjectKeyRegex.test(name)) {
- const [match] = name.match(inverseValidObjectKeyRegex) ?? []
- return !!match
- ? `Folder name cannot contain the "${match}" character`
- : 'Folder name contains an invalid special character'
- }
-
- return null
- },
-
addNewFolderPlaceholder: (columnIndex: number) => {
const isPrepend = true
const folderName = 'Untitled folder'
@@ -293,7 +264,7 @@ function createStorageExplorerState({
onError?: () => void
}) => {
const autofix = false
- const formattedName = state.sanitizeNameForDuplicateInColumn({
+ const formattedName = sanitizeNameForDuplicateInColumn(state, {
name: folderName,
autofix,
columnIndex,
@@ -308,7 +279,7 @@ function createStorageExplorerState({
return state.removeTempRows(columnIndex)
}
- const folderNameError = state.validateFolderName(formattedName)
+ const folderNameError = validateFolderName(formattedName)
if (folderNameError) {
onError?.()
return toast.error(folderNameError)
@@ -640,7 +611,7 @@ function createStorageExplorerState({
})
}
- const folderNameError = state.validateFolderName(newName)
+ const folderNameError = validateFolderName(newName)
if (folderNameError) {
onError?.()
return toast.error(folderNameError)
@@ -1103,7 +1074,7 @@ function createStorageExplorerState({
const path = file.path.split('/')
const topLevelFolder = path.length > 1 ? path[0] : null
if (topLevelFolders.includes(topLevelFolder as string)) {
- const newTopLevelFolder = state.sanitizeNameForDuplicateInColumn({
+ const newTopLevelFolder = sanitizeNameForDuplicateInColumn(state, {
name: topLevelFolder as string,
autofix,
columnIndex,
@@ -1144,7 +1115,7 @@ function createStorageExplorerState({
const isWithinFolder = (file?.path ?? '').split('/').length > 1
const fileName = !isWithinFolder
- ? state.sanitizeNameForDuplicateInColumn({ name: file.name, autofix })
+ ? sanitizeNameForDuplicateInColumn(state, { name: file.name, autofix })
: file.name
const unsanitizedFormattedFileName =
has(file, ['path']) && isWithinFolder ? file.path : fileName
@@ -1707,7 +1678,7 @@ function createStorageExplorerState({
columnIndex,
updatedName: newName,
})
- const pathToFile = state.getPathAlongFoldersToIndex(columnIndex)
+ const pathToFile = getPathAlongFoldersToIndex(state, columnIndex)
const fromPath = pathToFile.length > 0 ? `${pathToFile}/${originalName}` : originalName
const toPath = pathToFile.length > 0 ? `${pathToFile}/${newName}` : newName
@@ -1777,54 +1748,6 @@ function createStorageExplorerState({
}
},
- sanitizeNameForDuplicateInColumn: ({
- name,
- columnIndex,
- autofix = false,
- }: {
- name: string
- columnIndex?: number
- autofix?: boolean
- }) => {
- const columnIndex_ = columnIndex !== undefined ? columnIndex : state.getLatestColumnIndex()
- const currentColumn = state.columns[columnIndex_]
- const currentColumnItems = currentColumn.items.filter(
- (item) => item.status !== STORAGE_ROW_STATUS.EDITING
- )
- // [Joshen] JFYI storage does support folders of the same name with different casing
- // but its an issue with the List V1 endpoint that's causing an issue with fetching contents
- // for folders of the same name with different casing
- // We should remove this check once all projects are on the List V2 endpoint
- const hasSameNameInColumn =
- currentColumnItems.filter((item) => item.name.toLowerCase() === name.toLowerCase()).length >
- 0
-
- if (hasSameNameInColumn) {
- if (autofix) {
- const fileNameSegments = name.split('.')
- const fileName = fileNameSegments.slice(0, fileNameSegments.length - 1).join('.')
- const fileExt = fileNameSegments[fileNameSegments.length - 1]
-
- const dupeNameRegex = new RegExp(
- `${fileName} \\([-0-9]+\\)${fileExt ? '.' + fileExt : ''}$`
- )
- const itemsWithSameNameInColumn = currentColumnItems.filter((item) =>
- item.name.match(dupeNameRegex)
- )
-
- const updatedFileName = fileName + ` (${itemsWithSameNameInColumn.length + 1})`
- return fileExt ? `${updatedFileName}.${fileExt}` : updatedFileName
- } else {
- toast.error(
- `The name ${name} already exists in the current directory. Please use a different name.`
- )
- return null
- }
- }
-
- return name
- },
-
addTempRow: ({
type,
name,
@@ -1927,7 +1850,7 @@ function createStorageExplorerState({
return state
}
-type StorageExplorerState = ReturnType
+export type StorageExplorerState = ReturnType
const DEFAULT_STATE_CONFIG = {
projectRef: '',