Joshenlim/fe 4438 explorer allow deleting notebookchat without first having to (#50701)

### Context

Adds context menu to the explorer nav to allow users to delete notebooks
or chats from there (instead of having to open the notebook / chat
first, then select delete from the header)
<img width="304" height="339" alt="image"
src="https://github.com/user-attachments/assets/2ad0f21c-0d49-407c-97a2-ef11ae59e280"
/>

### Changes involved:
- Consolidates the rendering of explorer nav items into a single
component that both `ExplorerNavNotebook`, `ExplorerNavHome` and
`ExplorerNavChats` use.
- We were previously rendering a link for notebook, and a button for
chats. But chats' button was just calling `router.push` under the hood.
- Opting to use a Context to handle the delete functionality such that
we can render the confirmation modal just once at the layout level since
there's multiple places that have this delete functionality - notebook
tab, chat tab, context menu in each of the explorer navs

### To test
- [ ] Verify that navigating around the explorer is status quo
- [ ] Verify that you can delete a notebook/chat from within the
notebook/chat tab
- [ ] Verify that you can delete a notebook/chat from the explorer nav
- [ ] Verify that after deleting a notebook/chat from either locations,
the tabs should clear

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Unified chat and notebook navigation with direct links and active-item
highlighting.
- Added context-menu options to delete chats and notebooks with
confirmation and status notifications.
- Added double-click support for keeping recently viewed chats and
notebooks open as tabs.
  - Deleted items and their related tabs are removed automatically.

- **Tests**
- Updated Explorer navigation tests for the unified link-based item
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-09-22 16:21:55 +08:00
1 parent 2f1ad03640
commit 14bda8a5cf
11 files changed
+281 -165

No files matched your search

@@ -9,7 +9,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
ExplorerToolbar,
@@ -18,6 +17,7 @@ import {
ExplorerToolbarIcon,
ExplorerToolbarTitle,
} from './ExplorerToolbar'
import { useExplorerDeleteItem } from '@/components/layouts/ExplorerLayout/ExplorerProvider'
import { AIAssistantMetadataWarning } from '@/components/ui/AIAssistantPanel/AIAssistantMetadataWarning'
import type { AssistantChatHeaderProps } from '@/components/ui/AIAssistantPanel/AssistantChat'
import { ShortcutPills } from '@/components/ui/ShortcutTooltip'
@@ -41,7 +41,7 @@ export const ExplorerChatToolbar = ({
const snap = useAiAssistantStateSnapshot()
const chat = snap.chats[chatId]
const [isOptInModalOpen, setIsOptInModalOpen] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const { onSelectDelete } = useExplorerDeleteItem()
const handleCopyChatId = () => {
copyToClipboard(chatId, () => toast.success(`Copied chat ID for ${chat?.name}`))
@@ -51,12 +51,6 @@ export const ExplorerChatToolbar = ({
if (name.trim()) snap.renameChat(chatId, name.trim())
}
const handleDeleteChat = () => {
snap.deleteChat(chatId)
setIsDeleteModalOpen(false)
toast.success(`Deleted "${chat?.name}"`)
}
useShortcut(SHORTCUT_IDS.AI_ASSISTANT_COPY_CHAT_ID, handleCopyChatId, {
enabled: shortcutsEnabled && !isChatLoading,
})
@@ -105,7 +99,10 @@ export const ExplorerChatToolbar = ({
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-x-2" onClick={() => setIsDeleteModalOpen(true)}>
<DropdownMenuItem
className="gap-x-2"
onClick={() => onSelectDelete({ id: chatId, type: 'chat', name: chat?.name ?? '' })}
>
<Trash size={14} />
<span>Delete chat</span>
</DropdownMenuItem>
@@ -121,20 +118,6 @@ export const ExplorerChatToolbar = ({
updatedOptInSinceMCP={updatedOptInSinceMCP}
aiOptInLevel={aiOptInLevel}
/>
<ConfirmationModal
variant="destructive"
visible={isDeleteModalOpen}
title={`Delete "${chat?.name}"?`}
confirmLabel="Delete chat"
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteChat}
>
<p className="text-sm text-foreground-light">
This will permanently delete this chat and its message history. This action cannot be
undone.
</p>
</ConfirmationModal>
</div>
)
}
@@ -29,7 +29,6 @@ import {
SquareCode,
Trash,
} from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useEffectEvent, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
@@ -67,8 +66,8 @@ import { QueryCell } from './QueryCell'
import { type QueryEditorHandle } from './QueryEditor'
import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils'
import { checkDestructiveQuery } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
import { useExplorerDeleteItem } from '@/components/layouts/ExplorerLayout/ExplorerProvider'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
import {
evictNotebookFromCaches,
hasDiscardableChanges,
@@ -89,12 +88,12 @@ import {
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
export const ExplorerNotebookTab = () => {
const router = useRouter()
const { id, ref } = useParams()
const tabs = useTabsStateSnapshot()
const snap = useNotebooksStateSnapshot()
const queryClient = useQueryClient()
const { createChat, isCreating } = useCreateChat()
const { onSelectDelete } = useExplorerDeleteItem()
const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
@@ -108,7 +107,6 @@ export const ExplorerNotebookTab = () => {
const queryCellIds = cells.filter(isQueryCell).map((cell) => cell._id)
const [isRunningNotebook, setIsRunningNotebook] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isSaveBeforeAnalyzeOpen, setIsSaveBeforeAnalyzeOpen] = useState(false)
const [isSaveConflictOpen, setIsSaveConflictOpen] = useState(false)
const [pendingQueryMatches, setPendingQueryMatches] = useState<{
@@ -133,19 +131,6 @@ export const ExplorerNotebookTab = () => {
},
})
const { mutate: deleteNotebook, isPending: isDeleting } = useContentDeleteMutation({
onSuccess: () => {
toast.success('Successfully deleted notebook')
if (id) {
tabs.removeTab(createTabId('notebook', { id }))
snap.removeNotebook({ id })
}
setIsDeleteModalOpen(false)
router.push(`/project/${ref}/explorer`)
},
onError: (error) => toast.error(`Failed to delete notebook: ${error.message}`),
})
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
@@ -331,11 +316,6 @@ export const ExplorerNotebookTab = () => {
}
}
const handleConfirmDeleteNotebook = () => {
if (!ref || !id) return
deleteNotebook({ projectRef: ref, ids: [id] })
}
const handleDragEnd = (event: DragEndEvent) => {
persistNotebookTab()
@@ -443,7 +423,10 @@ export const ExplorerNotebookTab = () => {
<span>Copy as Markdown</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-x-2" onClick={() => setIsDeleteModalOpen(true)}>
<DropdownMenuItem
className="gap-x-2"
onClick={() => id && onSelectDelete({ id, type: 'notebook', name: name ?? '' })}
>
<Trash size={14} />
<span>Delete notebook</span>
</DropdownMenuItem>
@@ -533,22 +516,6 @@ export const ExplorerNotebookTab = () => {
</div>
</div>
<ConfirmationModal
size="small"
visible={isDeleteModalOpen}
title={`Confirm to delete notebook '${name ?? ''}'`}
confirmLabel="Delete notebook"
confirmLabelLoading="Deleting notebook"
variant="destructive"
loading={isDeleting}
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={handleConfirmDeleteNotebook}
>
<p className="text-sm">
This action cannot be undone. Are you sure you want to delete '{name}'?
</p>
</ConfirmationModal>
<ConfirmationModal
size="small"
visible={isSaveBeforeAnalyzeOpen}
@@ -5,6 +5,7 @@ import { HttpResponse } from 'msw'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ExplorerNotebookTab } from '../ExplorerNotebookTab'
import { ExplorerProvider } from '@/components/layouts/ExplorerLayout/ExplorerProvider'
import type { components } from '@/data/api'
import { contentKeys } from '@/data/content/keys'
import {
@@ -66,7 +67,9 @@ const seedNotebook = () => {
const renderNotebookTab = (queryClient: QueryClient, tabsState = createTabsState(PROJECT_REF)) =>
customRender(
<TabsStateContext.Provider value={tabsState}>
<ExplorerNotebookTab />
<ExplorerProvider>
<ExplorerNotebookTab />
</ExplorerProvider>
</TabsStateContext.Provider>,
{ queryClient }
)
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ExplorerNotebookTab } from '../ExplorerNotebookTab'
import { setCellSql } from '../QueryCell/QueryCell.utils'
import { createMarkdownCellSkeleton, createQueryCellSkeleton } from '../utils'
import { ExplorerProvider } from '@/components/layouts/ExplorerLayout/ExplorerProvider'
import { isQueryCell } from '@/data/content/notebooks/notebook-schema'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
import { notebooksState } from '@/state/notebooks/notebooks-state'
@@ -84,7 +85,9 @@ const seedNotebook = (cells: Notebooks.Cell[], status: 'new' | 'saved' = 'saved'
const renderNotebookTab = (tabsState = createTabsState('default')) =>
customRender(
<TabsStateContext.Provider value={tabsState}>
<ExplorerNotebookTab />
<ExplorerProvider>
<ExplorerNotebookTab />
</ExplorerProvider>
</TabsStateContext.Provider>
)
@@ -20,6 +20,7 @@ import { ExplorerNavChats } from './ExplorerNavChats'
import { ExplorerNavHeader } from './ExplorerNavHeader'
import { ExplorerNavHome } from './ExplorerNavHome'
import { ExplorerNavNotebooks } from './ExplorerNavNotebooks'
import { ExplorerProvider } from './ExplorerProvider'
import { useExplorerPreferences } from '@/components/interfaces/Account/Preferences/useExplorerPreferences'
import { ExplorerNotebookTabCoordinator } from '@/components/interfaces/Explorer/ExplorerNotebookTabCoordinator'
import { ExplorerQueryTabCoordinator } from '@/components/interfaces/Explorer/ExplorerQueryTabCoordinator'
@@ -75,42 +76,44 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout
}
return (
<ProjectLayoutWithAuth
product="Explorer"
browserTitle={mergedBrowserTitle}
productMenuHeader={
<ExplorerNavHeader
section={section}
onBack={() => setSection(undefined)}
rootAction={<BackToSqlEditorButton />}
/>
}
productMenu={
<div className="relative h-full overflow-hidden">
<AnimatePresence mode="wait">
{section === undefined && <ExplorerNavHome key="home" onSelectSection={setSection} />}
{section === 'notebook' && <ExplorerNavNotebooks key="notebooks" />}
{section === 'chat' && <ExplorerNavChats key="chats" />}
</AnimatePresence>
</div>
}
>
<ExplorerQueryTabCoordinator />
<ExplorerNotebookTabCoordinator />
<div className="flex flex-col h-full">
<div className={cn('h-10 md:min-h-(--header-height) flex items-center bg-surface-100')}>
<EditorTabs
isCollapseButtonHidden
customTabs={shouldShowHomeTab ? <HomeTabButton /> : undefined}
newTabButton={<NewTabButton />}
onTabChange={handleTabChange}
<ExplorerProvider>
<ProjectLayoutWithAuth
product="Explorer"
browserTitle={mergedBrowserTitle}
productMenuHeader={
<ExplorerNavHeader
section={section}
onBack={() => setSection(undefined)}
rootAction={<BackToSqlEditorButton />}
/>
}
productMenu={
<div className="relative h-full overflow-hidden">
<AnimatePresence mode="wait">
{section === undefined && <ExplorerNavHome key="home" onSelectSection={setSection} />}
{section === 'notebook' && <ExplorerNavNotebooks key="notebooks" />}
{section === 'chat' && <ExplorerNavChats key="chats" />}
</AnimatePresence>
</div>
}
>
<ExplorerQueryTabCoordinator />
<ExplorerNotebookTabCoordinator />
<div className="flex flex-col h-full">
<div className={cn('h-10 md:min-h-(--header-height) flex items-center bg-surface-100')}>
<EditorTabs
isCollapseButtonHidden
customTabs={shouldShowHomeTab ? <HomeTabButton /> : undefined}
newTabButton={<NewTabButton />}
onTabChange={handleTabChange}
/>
</div>
<div className="flex-grow min-h-0">{children}</div>
</div>
<div className="flex-grow min-h-0">{children}</div>
</div>
</ProjectLayoutWithAuth>
</ProjectLayoutWithAuth>
</ExplorerProvider>
)
}
@@ -36,6 +36,10 @@ vi.mock('@/components/interfaces/Explorer/hooks', () => ({
useCreateChat: () => ({ openChat: vi.fn() }),
}))
vi.mock('./ExplorerProvider', () => ({
useExplorerDeleteItem: () => ({ confirmDelete: vi.fn() }),
}))
vi.mock('@/state/ai-assistant-state', () => ({
useAiAssistantChatList: () => [
{ id: 'old-chat', name: 'Older investigation', updatedAt: new Date('2026-01-01') },
@@ -54,20 +58,20 @@ describe('ExplorerNavChats', () => {
it('filters support chats, safely sorts rehydrated chats, and marks the route active', () => {
customRender(<ExplorerNavChats />)
const chatButtons = screen.getAllByRole('button')
expect(chatButtons.map((button) => button.textContent)).toEqual([
const chatLinks = screen.getAllByRole('link')
expect(chatLinks.map((link) => link.textContent)).toEqual([
'Recent investigation',
'Older investigation',
'Rehydrated chat',
])
expect(chatButtons[0]).toHaveClass('active')
expect(chatLinks[0]).toHaveClass('active')
expect(screen.queryByText('Support conversation')).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox', { name: 'Search chats' }), {
target: { value: 'rehydrated' },
})
expect(screen.getByRole('button')).toHaveTextContent('Rehydrated chat')
expect(screen.getByRole('link')).toHaveTextContent('Rehydrated chat')
expect(screen.queryByText('Recent investigation')).not.toBeInTheDocument()
})
})
@@ -1,11 +1,10 @@
import { useParams } from 'common'
import { MessageSquare } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { cn } from 'ui'
import { ExplorerNavResourceWrapper, rowClassName } from './ExplorerLayout.constants'
import { useCreateChat } from '@/components/interfaces/Explorer/hooks'
import { ExplorerNavResourceWrapper } from './ExplorerLayout.constants'
import { ExplorerNavItem } from './ExplorerNavItem'
import { useExplorerDeleteItem } from './ExplorerProvider'
import type { ChatSession } from '@/state/ai-assistant-state'
import { useAiAssistantChatList } from '@/state/ai-assistant-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
@@ -22,10 +21,10 @@ const getVisibleChats = (chats: ChatSession[], search: string): ChatSession[] =>
export const ExplorerNavChats = () => {
const [search, setSearch] = useState('')
const router = useRouter()
const { id } = useParams()
const { openChat } = useCreateChat()
const { id, ref } = useParams()
const chatList = useAiAssistantChatList()
const tabs = useTabsStateSnapshot()
const { onSelectDelete } = useExplorerDeleteItem()
const chats = getVisibleChats(chatList, search)
@@ -41,20 +40,17 @@ export const ExplorerNavChats = () => {
const isActive = router.pathname.includes('/explorer/chat/') && id === chat.id
return (
<button
<ExplorerNavItem
type="chat"
key={chat.id}
type="button"
tabIndex={0}
className={rowClassName(isActive)}
onClick={() => openChat(chat.id)}
name={chat.name}
isActive={isActive}
href={`/project/${ref}/explorer/chat/${chat.id}`}
onDoubleClick={() => tabs.makeTabPermanent(createTabId('chat', { id: chat.id }))}
>
<MessageSquare
size={14}
className={cn('shrink-0', isActive && 'text-foreground')}
/>
<span className="truncate text-left">{chat.name}</span>
</button>
onSelectDelete={() =>
onSelectDelete({ id: chat.id, type: 'chat', name: chat.name })
}
/>
)
})
)}
@@ -13,7 +13,9 @@ import {
rowClassName,
} from './ExplorerLayout.constants'
import { formatRelativeTimeShort, getRecentlyUpdatedItems } from './ExplorerNavHome.utils'
import { useCreateChat, useCreateQuery } from '@/components/interfaces/Explorer/hooks'
import { ExplorerNavItem } from './ExplorerNavItem'
import { useExplorerDeleteItem } from './ExplorerProvider'
import { 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'
@@ -26,12 +28,12 @@ export const ExplorerNavHome = ({
onSelectSection: (section: ExplorerResourceType) => void
}) => {
const router = useRouter()
const { ref } = useParams()
const { id, ref } = useParams()
const tabs = useTabsStateSnapshot()
const appStateSnapshot = useAppStateSnapshot()
const { openChat } = useCreateChat()
const { createQuery } = useCreateQuery()
const { onSelectDelete } = useExplorerDeleteItem()
const { data: notebooksData } = useNotebooksInfiniteQuery({ projectRef: ref, limit: 100 })
const notebooks = notebooksData?.pages.flatMap((page) => page.content) ?? []
@@ -103,39 +105,34 @@ export const ExplorerNavHome = ({
<p className="px-2 text-xs text-foreground-lighter">Nothing edited yet</p>
) : (
recentItems.map((item) => {
const Icon = EXPLORER_SECTIONS.find((section) => section.type === item.type)?.icon
const content = (
<>
{Icon && <Icon size={14} className="shrink-0" aria-hidden="true" />}
<span className="flex-1 truncate text-left">{item.label}</span>
<span className="shrink-0 text-xs text-foreground-lighter">
{formatRelativeTimeShort(item.updatedAt)}
</span>
</>
)
const isActive = id === item.id
return item.type === 'chat' ? (
<button
const href =
item.type === 'chat'
? `/project/${ref}/explorer/chat/${item.id}`
: `/project/${ref}/explorer/notebook/${item.id}`
const onDoubleClick = () => {
if (item.type === 'chat') {
tabs.makeTabPermanent(createTabId('chat', { id: item.id }))
} else {
tabs.makeTabPermanent(createTabId('notebook', { id: item.id }))
}
}
return (
<ExplorerNavItem
key={item.id}
type="button"
tabIndex={0}
className={rowClassName(false)}
onClick={() => openChat(item.id)}
onDoubleClick={() => tabs.makeTabPermanent(createTabId('chat', { id: item.id }))}
>
{content}
</button>
) : (
<Link
key={item.id}
href={`/project/${ref}/explorer/notebook/${item.id}`}
className={rowClassName(false)}
onDoubleClick={() =>
tabs.makeTabPermanent(createTabId('notebook', { id: item.id }))
type={item.type}
href={href}
name={item.label}
isActive={isActive}
onDoubleClick={onDoubleClick}
onSelectDelete={() =>
onSelectDelete({ id: item.id, type: item.type, name: item.label })
}
>
{content}
</Link>
description={formatRelativeTimeShort(item.updatedAt)}
/>
)
})
)}
@@ -0,0 +1,57 @@
import { MessageSquare, NotebookText, Trash } from 'lucide-react'
import Link from 'next/link'
import { type CSSProperties } from 'react'
import { cn, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from 'ui'
import { rowClassName } from './ExplorerLayout.constants'
interface ExplorerNavItemProps {
href: string
type: 'notebook' | 'chat'
name: string
description?: string
isActive?: boolean
style?: CSSProperties
onDoubleClick: () => void
onSelectDelete: () => void
}
export const ExplorerNavItem = ({
href,
type,
name,
description,
isActive = false,
style,
onDoubleClick,
onSelectDelete,
}: ExplorerNavItemProps) => {
return (
<ContextMenu modal={false}>
<ContextMenuTrigger asChild>
<Link
href={href}
className={rowClassName(isActive)}
style={style}
onDoubleClick={onDoubleClick}
>
{type === 'notebook' ? (
<NotebookText size={14} className={cn('shrink-0', isActive && 'text-foreground')} />
) : (
<MessageSquare size={14} className={cn('shrink-0', isActive && 'text-foreground')} />
)}
<span className="flex-1 truncate text-left">{name}</span>
{!!description && (
<span className="shrink-0 text-xs text-foreground-lighter">{description}</span>
)}
</Link>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem className="gap-x-2" onSelect={onSelectDelete}>
<Trash size={14} />
<span>Delete {type}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
@@ -1,13 +1,12 @@
import { useDebounce } from '@uidotdev/usehooks'
import { useParams } from 'common'
import { NotebookText } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
import { cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { ExplorerNavResourceWrapper, rowClassName } from './ExplorerLayout.constants'
import { ExplorerNavResourceWrapper } from './ExplorerLayout.constants'
import { ExplorerNavItem } from './ExplorerNavItem'
import { useExplorerDeleteItem } from './ExplorerProvider'
import {
InfiniteListDefault,
LoaderForIconMenuItems,
@@ -24,6 +23,7 @@ const NOTEBOOK_ROW_HEIGHT = 28
type NotebookListItemProps = RowComponentBaseProps<NotebookRow> & {
projectRef: string | undefined
activeNotebookId: string | undefined
onSelectDelete: (item: { id: string; type: 'notebook' | 'chat'; name: string }) => void
}
const NotebookListItem = ({
@@ -31,26 +31,30 @@ const NotebookListItem = ({
style,
projectRef,
activeNotebookId,
onSelectDelete,
}: NotebookListItemProps) => {
const isActive = activeNotebookId === notebook.id
const tabs = useTabsStateSnapshot()
return (
<Link
href={`/project/${projectRef}/explorer/notebook/${notebook.id}`}
className={rowClassName(isActive)}
<ExplorerNavItem
name={notebook.name}
type="notebook"
isActive={isActive}
style={style}
href={`/project/${projectRef}/explorer/notebook/${notebook.id}`}
onDoubleClick={() => tabs.makeTabPermanent(createTabId('notebook', { id: notebook.id }))}
>
<NotebookText size={14} className={cn('shrink-0', isActive && 'text-foreground')} />
<span className="truncate text-left">{notebook.name}</span>
</Link>
onSelectDelete={() =>
onSelectDelete({ id: notebook.id, type: 'notebook', name: notebook.name })
}
/>
)
}
export const ExplorerNavNotebooks = () => {
const router = useRouter()
const { ref, id } = useParams()
const { onSelectDelete } = useExplorerDeleteItem()
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 500)
@@ -74,7 +78,10 @@ export const ExplorerNavNotebooks = () => {
const activeNotebookId = router.pathname.includes('/explorer/notebook/') ? id : undefined
const itemProps = useMemo(() => ({ projectRef: ref, activeNotebookId }), [ref, activeNotebookId])
const itemProps = useMemo(
() => ({ projectRef: ref, activeNotebookId, onSelectDelete }),
[ref, activeNotebookId, onSelectDelete]
)
return (
<ExplorerNavResourceWrapper type="notebook" search={search} setSearch={setSearch}>
@@ -0,0 +1,96 @@
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { createContext, useContext, useState, type PropsWithChildren } from 'react'
import { toast } from 'sonner'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
type ExplorerDeleteItem = { id: string; type: 'notebook' | 'chat'; name: string }
interface ExplorerContextValue {
onSelectDelete: (item: ExplorerDeleteItem) => void
}
const ExplorerContext = createContext<ExplorerContextValue | undefined>(undefined)
export const useExplorerDeleteItem = () => {
const context = useContext(ExplorerContext)
if (!context) {
throw new Error('useExplorerDeleteItem must be used within an ExplorerProvider')
}
return context
}
export const ExplorerProvider = ({ children }: PropsWithChildren) => {
const router = useRouter()
const { ref } = useParams()
const tabs = useTabsStateSnapshot()
const notebooksSnap = useNotebooksStateSnapshot()
const aiAssistantSnap = useAiAssistantStateSnapshot()
const { setLastVisitedExplorerTab } = useDashboardHistory()
const [pending, setPending] = useState<ExplorerDeleteItem | null>(null)
const onClearDashboardHistory = () => setLastVisitedExplorerTab(undefined)
const closeTabForItem = (item: ExplorerDeleteItem) => {
tabs.handleTabClose({
id: createTabId(item.type, { id: item.id }),
router,
editor: 'explorer',
onClearDashboardHistory,
})
}
const { mutate: deleteNotebook, isPending: isDeletingNotebook } = useContentDeleteMutation({
onSuccess: () => {
if (!pending) return
toast.success('Successfully deleted notebook')
notebooksSnap.removeNotebook({ id: pending.id })
closeTabForItem(pending)
setPending(null)
},
onError: (error) => toast.error(`Failed to delete notebook: ${error.message}`),
})
const handleConfirm = () => {
if (!pending) return
if (pending.type === 'notebook') {
if (!ref) return
deleteNotebook({ projectRef: ref, ids: [pending.id] })
} else {
aiAssistantSnap.deleteChat(pending.id)
toast.success(`Deleted "${pending.name}"`)
closeTabForItem(pending)
setPending(null)
}
}
return (
<ExplorerContext.Provider value={{ onSelectDelete: setPending }}>
{children}
<ConfirmationModal
size="small"
variant="destructive"
visible={!!pending}
title={`Confirm to delete ${pending?.type} '${pending?.name ?? ''}'`}
confirmLabel={`Delete ${pending?.type}`}
confirmLabelLoading="Deleting..."
loading={isDeletingNotebook}
onCancel={() => setPending(null)}
onConfirm={handleConfirm}
>
<p className="text-sm text-foreground-light">
This action cannot be undone. Are you sure you want to delete '{pending?.name}'?
</p>
</ConfirmationModal>
</ExplorerContext.Provider>
)
}