Files
Joshen Lim 14bda8a5cf 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 -->
2026-09-22 16:21:55 +08:00

155 lines
5.6 KiB
TypeScript

import { useParams } from 'common'
import { motion } from 'framer-motion'
import { ChevronRight, Plus, Settings, SquareCode } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import {
EXPLORER_SECTIONS,
ExplorerResourceType,
LEVEL_OFFSET,
LEVEL_TRANSITION,
rowClassName,
} from './ExplorerLayout.constants'
import { formatRelativeTimeShort, getRecentlyUpdatedItems } from './ExplorerNavHome.utils'
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'
import { useAppStateSnapshot } from '@/state/app-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
export const ExplorerNavHome = ({
onSelectSection,
}: {
onSelectSection: (section: ExplorerResourceType) => void
}) => {
const router = useRouter()
const { id, ref } = useParams()
const tabs = useTabsStateSnapshot()
const appStateSnapshot = useAppStateSnapshot()
const { createQuery } = useCreateQuery()
const { onSelectDelete } = useExplorerDeleteItem()
const { data: notebooksData } = useNotebooksInfiniteQuery({ projectRef: ref, limit: 100 })
const notebooks = notebooksData?.pages.flatMap((page) => page.content) ?? []
const chats = useAiAssistantChatList()
const { data: notebookCountData, isPending } = useContentCountQuery({
projectRef: ref,
type: 'notebook',
})
// [Joshen] Notebooks are all shared by default, none private
const notebookCount = notebookCountData?.shared ?? 0
const recentItems = getRecentlyUpdatedItems({ notebooks, chats })
return (
<motion.div
key="root"
role="group"
aria-label="All resources"
initial={{ opacity: 0, x: -LEVEL_OFFSET }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -LEVEL_OFFSET }}
transition={LEVEL_TRANSITION}
className="absolute inset-0 flex flex-col overflow-hidden"
>
<div className="flex-1 min-h-0 flex flex-col gap-4 overflow-y-auto p-3">
<nav className="flex flex-col gap-px">
<button
type="button"
tabIndex={0}
className={rowClassName(false)}
onClick={() => createQuery()}
>
<SquareCode size={14} className="shrink-0" />
<span className="flex-1 text-left">Run SQL</span>
<Plus size={14} className="shrink-0 text-foreground-muted" />
</button>
{EXPLORER_SECTIONS.map(({ type, label, icon: Icon }) => {
return (
<button
key={type}
type="button"
tabIndex={0}
className={rowClassName(false)}
onClick={() => onSelectSection(type)}
>
<Icon size={14} className="shrink-0" />
<span className="flex-1 text-left">{label}</span>
{type === 'notebook' ? (
isPending ? (
<ShimmeringLoader className="w-3 py-2" />
) : (
<span className="text-xs text-foreground-lighter">{notebookCount}</span>
)
) : (
<span className="text-xs text-foreground-lighter">{chats.length}</span>
)}
<ChevronRight size={14} className="shrink-0 text-foreground-muted" />
</button>
)
})}
</nav>
<section className="flex flex-col gap-px">
<h3 className="mb-2 px-2 font-mono text-sm font-normal uppercase text-foreground-lighter">
Recently updated
</h3>
{recentItems.length === 0 ? (
<p className="px-2 text-xs text-foreground-lighter">Nothing edited yet</p>
) : (
recentItems.map((item) => {
const isActive = id === item.id
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={item.type}
href={href}
name={item.label}
isActive={isActive}
onDoubleClick={onDoubleClick}
onSelectDelete={() =>
onSelectDelete({ id: item.id, type: item.type, name: item.label })
}
description={formatRelativeTimeShort(item.updatedAt)}
/>
)
})
)}
</section>
</div>
<div className="shrink-0 border-t border-default p-3">
<Link
href="/account/me#dashboard"
className={rowClassName(false)}
onClick={() => appStateSnapshot.setLastRouteBeforeVisitingAccountPage(router.asPath)}
>
<Settings size={14} className="shrink-0" />
<span className="flex-1 text-left">Preferences</span>
</Link>
</div>
</motion.div>
)
}