mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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}
|
||||
|
||||
+4
-1
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user