feat: add desktop main navigation

This commit is contained in:
Jingyi-Dify 2026-05-01 12:25:43 -07:00
parent 203b3a9499
commit 3b4c0b4f97
27 changed files with 1299 additions and 21 deletions

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(3 3) scale(1.5)">
<path d="M7 10.5C8.933 10.5 10.5 8.4853 10.5 6C10.5 3.51472 8.933 1.5 7 1.5M7 10.5C5.067 10.5 3.5 8.4853 3.5 6C3.5 3.51472 5.067 1.5 7 1.5M7 10.5H5C3.06701 10.5 1.5 8.4853 1.5 6C1.5 3.51472 3.06701 1.5 5 1.5H7" stroke="currentColor" stroke-width="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15.25C12.4142 15.25 12.75 15.5858 12.75 16V16.0098C12.75 16.424 12.4142 16.7598 12 16.7598C11.5858 16.7598 11.25 16.424 11.25 16.0098V16C11.25 15.5858 11.5858 15.25 12 15.25Z" fill="currentColor"/>
<path d="M14 7.25C14.4142 7.25 14.75 7.58579 14.75 8V10.5C14.75 10.7359 14.6389 10.958 14.4502 11.0996L12.75 12.374V13C12.75 13.4142 12.4142 13.75 12 13.75C11.5858 13.75 11.25 13.4142 11.25 13V12C11.25 11.7641 11.3611 11.542 11.5498 11.4004L13.25 10.125V8.75H10.75V9C10.75 9.41421 10.4142 9.75 10 9.75C9.58579 9.75 9.25 9.41421 9.25 9V8C9.25 7.58579 9.58579 7.25 10 7.25H14Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 716 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-5.5 -7.3)">
<path d="M14.449 8.37314C15.0613 7.87562 15.9387 7.87562 16.551 8.37314L22.3843 13.1127C22.7738 13.4292 23 13.9044 23 14.4063V22.7596C23 23.6801 22.2538 24.4263 21.3333 24.4263H18.8333V17.7599C18.8333 17.2997 18.4602 16.9266 18 16.9266H13C12.5398 16.9266 12.1667 17.2997 12.1667 17.7599V24.4263H9.66667C8.74619 24.4263 8 23.6801 8 22.7596V14.4063C8 13.9044 8.22616 13.4292 8.61568 13.1127L14.449 8.37314Z" fill="currentColor"/>
<path d="M13.833 24.4263H17.1663V18.5933H13.833V24.4263Z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.33301 7.71788C3.33301 7.48475 3.33301 7.36818 3.36243 7.2604C3.38851 7.16492 3.43138 7.07484 3.48905 6.99439C3.55414 6.90359 3.64461 6.83008 3.82555 6.68307L9.15892 2.34973C9.45859 2.10622 9.60842 1.98447 9.77501 1.93783C9.92201 1.8967 10.0773 1.8967 10.2243 1.93783C10.3909 1.98447 10.5408 2.10622 10.8404 2.34973L16.1738 6.68307C16.3548 6.83008 16.4452 6.90359 16.5103 6.99439C16.568 7.07484 16.6108 7.16492 16.6369 7.2604C16.6663 7.36818 16.6663 7.48475 16.6663 7.71788V15.3333C16.6663 15.7999 16.6663 16.0334 16.5755 16.2116C16.4956 16.3684 16.3682 16.4959 16.2113 16.5758C16.0331 16.6666 15.7998 16.6666 15.333 16.6666H4.66634C4.19963 16.6666 3.96627 16.6666 3.78802 16.5758C3.63122 16.4959 3.50373 16.3684 3.42383 16.2116C3.33301 16.0334 3.33301 15.7999 3.33301 15.3333V7.71788Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M12.5 16.6666V11.8333C12.5 11.281 12.0523 10.8333 11.5 10.8333H8.5C7.94772 10.8333 7.5 11.281 7.5 11.8333V16.6666" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-6.33 -5.5)">
<path d="M9.66602 8.83333C9.66602 8.3731 10.0391 8 10.4993 8H14.666C15.1263 8 15.4993 8.3731 15.4993 8.83333V10.5H9.66602V8.83333Z" fill="currentColor"/>
<path d="M17.166 8.83333C17.166 8.3731 17.5391 8 17.9993 8H22.166C22.6263 8 22.9993 8.3731 22.9993 8.83333V10.5H17.166V8.83333Z" fill="currentColor"/>
<path d="M8 13.0001C8 12.5398 8.3731 12.1667 8.83333 12.1667H23.8333C24.2936 12.1667 24.6667 12.5398 24.6667 13.0001V21.3334C24.6667 21.7937 24.2936 22.1667 23.8333 22.1667H8.83333C8.3731 22.1667 8 21.7937 8 21.3334V13.0001Z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 715 B

View File

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 7.50008C2.5 7.03984 2.8731 6.66675 3.33333 6.66675H16.6667C17.1269 6.66675 17.5 7.03985 17.5 7.50008V15.0001C17.5 15.4603 17.1269 15.8334 16.6667 15.8334H3.33333C2.8731 15.8334 2.5 15.4603 2.5 15.0001V7.50008Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.16699 6.66659V4.58325C4.16699 3.89289 4.72663 3.33325 5.41699 3.33325H7.08366C7.77402 3.33325 8.33366 3.89289 8.33366 4.58325V6.66659" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.667 6.66659V4.16659C11.667 3.70635 12.0401 3.33325 12.5003 3.33325H15.0003C15.4606 3.33325 15.8337 3.70635 15.8337 4.16659V6.66659" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

View File

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-4.667 -6.333)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 8C9.11929 8 8 9.11929 8 10.5V22.1667C8 23.5474 9.11929 24.6667 10.5 24.6667H20.5C20.9602 24.6667 21.3333 24.2936 21.3333 23.8333V8.83333C21.3333 8.3731 20.9602 8 20.5 8H10.5ZM9.66667 22.1667C9.66667 22.6269 10.0398 23 10.5 23H19.6667V21.3333H10.5C10.0398 21.3333 9.66667 21.7064 9.66667 22.1667ZM12.1667 11.3333C11.7064 11.3333 11.3333 11.7064 11.3333 12.1667C11.3333 12.6269 11.7064 13 12.1667 13H17.1667C17.6269 13 18 12.6269 18 12.1667C18 11.7064 17.6269 11.3333 17.1667 11.3333H12.1667ZM11.3333 15.5C11.3333 15.0397 11.7064 14.6667 12.1667 14.6667H14.6667C15.1269 14.6667 15.5 15.0397 15.5 15.5C15.5 15.9602 15.1269 16.3333 14.6667 16.3333H12.1667C11.7064 16.3333 11.3333 15.9602 11.3333 15.5Z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 933 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 9.16675H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 5.83325H12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.16699 4.16667C4.16699 3.24619 4.91318 2.5 5.83366 2.5H15.417C15.6471 2.5 15.8337 2.68655 15.8337 2.91667V17.5H5.83366C4.91318 17.5 4.16699 16.7538 4.16699 15.8333V4.16667Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M4.16699 15.8334C4.16699 14.9129 4.91318 14.1667 5.83366 14.1667H15.8337V17.5001H5.83366C4.91318 17.5001 4.16699 16.7539 4.16699 15.8334Z" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-6.3 -5.5)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.25369 8.58477C9.3624 8.23688 9.6846 8 10.0491 8H22.5491C22.9136 8 23.2357 8.23688 23.3445 8.58477L24.4481 12.1162C24.8042 13.256 24.5023 14.4059 23.7991 15.2164V22.1667C23.7991 22.6269 23.426 23 22.9657 23H9.63242C9.17219 23 8.79909 22.6269 8.79909 22.1667V15.2164C8.09588 14.4059 7.79393 13.256 8.15013 12.1162L9.25369 8.58477ZM18.0271 12.7092L17.6467 9.66667H14.9514L14.5711 12.7092C14.4412 13.7486 15.2516 14.6667 16.2991 14.6667C17.3465 14.6667 18.1568 13.7485 18.0271 12.7092ZM13.2718 9.66667H10.6617L9.74093 12.6133C9.42266 13.6317 10.1835 14.6667 11.2505 14.6667C12.0482 14.6667 12.721 14.0728 12.82 13.2812L13.2718 9.66667ZM19.3264 9.66667L19.6809 12.5025L19.7782 13.2812C19.8772 14.0728 20.55 14.6667 21.3477 14.6667C22.4147 14.6667 23.1755 13.6317 22.8572 12.6133L21.9364 9.66667H19.3264Z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.667 9.99992V16.6666H3.33366V9.99992M7.91699 3.33325H12.0837M7.91699 3.33325L7.44543 7.10578C7.25334 8.6425 8.45158 9.99992 10.0003 9.99992C11.5491 9.99992 12.7473 8.6425 12.5552 7.10578L12.0837 3.33325M7.91699 3.33325H3.75033L2.64677 6.86465C2.16081 8.41975 3.32257 9.99992 4.95179 9.99992C6.1697 9.99992 7.19703 9.093 7.34809 7.88451L7.91699 3.33325ZM12.0837 3.33325H16.2503L17.3539 6.86465C17.8398 8.41975 16.6781 9.99992 15.0489 9.99992C13.831 9.99992 12.8037 9.093 12.6526 7.88451L12.0837 3.33325Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 712 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0004 1.875C17.0398 1.87509 21.1246 5.9602 21.1249 10.9995C21.1249 13.1138 20.4037 15.0582 19.1972 16.6055L21.7958 19.2041C22.235 19.6433 22.2348 20.3556 21.7958 20.7949C21.3565 21.2343 20.6443 21.2343 20.205 20.7949L17.6064 18.1963C16.3417 19.1831 14.8123 19.8466 13.14 20.0552C12.5235 20.132 11.9616 19.6932 11.8847 19.0767C11.8081 18.4603 12.2454 17.8981 12.8617 17.8213C16.2516 17.3983 18.8749 14.5044 18.8749 10.9995C18.8746 7.20283 15.7971 4.12509 12.0004 4.125C8.4954 4.12505 5.60139 6.74948 5.17862 10.1396C5.10154 10.7559 4.53955 11.1934 3.92325 11.1167C3.30688 11.0398 2.86963 10.4777 2.9462 9.86133C3.50765 5.35896 7.34631 1.87505 12.0004 1.875Z" fill="currentColor"/>
<path d="M3.70727 16.1747L7.91781 11.2624C8.24038 10.8861 8.85505 11.158 8.79357 11.6498L8.49979 14.0001H10.9127C11.3399 14.0001 11.5703 14.5012 11.2923 14.8255L7.08177 19.7378C6.7592 20.1141 6.14453 19.8422 6.20601 19.3504L6.49979 17.0001H4.0869C3.65972 17.0001 3.42927 16.499 3.70727 16.1747Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,8 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-7.14 -5.08)">
<path d="M13.7969 17.1665C14.465 17.1665 15.1067 17.2803 15.7045 17.4872L14.7247 20.9165H12.9636C11.8131 20.9167 10.8803 21.8493 10.8803 22.9998C10.8803 23.2963 10.9436 23.5778 11.0552 23.8332H8.79695C8.33682 23.833 7.95953 23.4587 8.00349 23.0007C8.30296 19.8813 10.2937 17.1666 13.7969 17.1665Z" fill="currentColor"/>
<path d="M25.4632 16.3333C25.7246 16.3333 25.9715 16.4558 26.1289 16.6645C26.2668 16.8473 26.3224 17.0777 26.286 17.3008L26.2648 17.3953L24.5981 23.2286C24.4959 23.5863 24.1686 23.8333 23.7965 23.8333H12.9632C12.5031 23.8331 12.1299 23.4601 12.1299 22.9999C12.1299 22.5398 12.5031 22.1668 12.9632 22.1666H15.6675L17.1616 16.9379L17.2105 16.8093C17.3465 16.5223 17.6376 16.3333 17.9632 16.3333H25.4632Z" fill="currentColor"/>
<path d="M14.2132 9.25C16.0541 9.25 17.5465 10.7424 17.5465 12.5833C17.5465 14.4243 16.0541 15.9167 14.2132 15.9167C12.3724 15.9165 10.8799 14.4242 10.8799 12.5833C10.8799 10.7425 12.3724 9.25013 14.2132 9.25Z" fill="currentColor"/>
<path d="M22.5474 8C22.7539 8.00029 22.9276 8.15533 22.951 8.36052C23.0941 9.62402 23.8103 10.3975 25.093 10.5114C25.3029 10.53 25.4635 10.7067 25.4632 10.9175C25.4628 11.128 25.3019 11.3037 25.0921 11.3219C23.8276 11.4314 23.0613 12.1977 22.9518 13.4622C22.9335 13.672 22.758 13.833 22.5474 13.8333C22.3367 13.8336 22.1609 13.6728 22.1421 13.4631C22.0281 12.1805 21.2546 11.4635 19.9912 11.3203C19.786 11.2971 19.6303 11.124 19.6299 10.9175C19.6297 10.7108 19.7851 10.5367 19.9904 10.513C21.2719 10.3651 21.9951 9.64128 22.1429 8.3597C22.1667 8.15453 22.3408 7.99979 22.5474 8Z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.8206 2.0275C15.7973 1.82217 15.6238 1.66696 15.4171 1.66675C15.2104 1.66654 15.0365 1.82139 15.0128 2.02667C14.865 3.30836 14.1416 4.03176 12.8599 4.17959C12.6547 4.20326 12.4998 4.37719 12.5 4.58383C12.5003 4.79047 12.6554 4.96408 12.8608 4.98733C14.1243 5.13046 14.8978 5.84689 15.0117 7.12955C15.0304 7.33946 15.2064 7.50032 15.4171 7.50008C15.6278 7.49984 15.8035 7.33859 15.8217 7.12863C15.9311 5.86411 16.6973 5.09787 17.9619 4.98841C18.1718 4.97023 18.3331 4.79461 18.3333 4.58387C18.3336 4.37313 18.1728 4.19715 17.9628 4.17851C16.6802 4.06457 15.9637 3.29101 15.8206 2.0275Z" fill="currentColor"/>
<path d="M7.29167 9.16659C8.9025 9.16659 10.2083 7.86075 10.2083 6.24992C10.2083 4.63909 8.9025 3.33325 7.29167 3.33325C5.68084 3.33325 4.375 4.63909 4.375 6.24992C4.375 7.86075 5.68084 9.16659 7.29167 9.16659Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M1.66699 16.6667C1.66699 13.9053 3.90557 11.6667 6.66699 11.6667H7.08366" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.16634 16.6666L10.833 10.8333H18.333L16.6663 16.6666H9.16634ZM9.16634 16.6666H5.83301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 277,
"total": 290,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -5,8 +5,7 @@ import InSiteMessageNotification from '@/app/components/app/in-site-message/noti
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import MainNavLayout from '@/app/components/main-nav/layout'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@ -24,12 +23,11 @@ const Layout = ({ children }: { children: ReactNode }) => {
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<RoleRouteGuard>
{children}
</RoleRouteGuard>
<MainNavLayout>
<RoleRouteGuard>
{children}
</RoleRouteGuard>
</MainNavLayout>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />

View File

@ -7,6 +7,9 @@ import Explore from '../index'
const mockReplace = vi.fn()
const mockPush = vi.fn()
const mockInstalledAppsData = { installed_apps: [] as const }
type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType]
let mockMediaType: MediaTypeValue = MediaType.pc
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
@ -17,7 +20,7 @@ vi.mock('@/next/navigation', () => ({
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => MediaType.pc,
default: () => mockMediaType,
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
@ -45,6 +48,7 @@ vi.mock('@/context/app-context', () => ({
describe('Explore', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMediaType = MediaType.pc
;(useAppContext as Mock).mockReturnValue({
isCurrentWorkspaceDatasetOperator: false,
})
@ -60,6 +64,28 @@ describe('Explore', () => {
expect(screen.getByText('child')).toBeInTheDocument()
})
it('should not render the legacy explore sidebar on desktop', () => {
render((
<Explore>
<div>child</div>
</Explore>
))
expect(screen.queryByText('explore.sidebar.title')).not.toBeInTheDocument()
})
it('should keep the legacy explore sidebar on mobile', () => {
mockMediaType = MediaType.mobile
render((
<Explore>
<div>child</div>
</Explore>
))
expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
})
})
describe('Effects', () => {

View File

@ -1,15 +1,19 @@
'use client'
import * as React from 'react'
import Sidebar from '@/app/components/explore/sidebar'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
const Explore = ({
children,
}: {
children: React.ReactNode
}) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
return (
<div className="flex h-full overflow-hidden border-t border-divider-regular bg-background-body">
<Sidebar />
{isMobile && <Sidebar />}
<div className="h-full min-h-0 w-0 grow">
{children}
</div>

View File

@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react'
import { useGotoAnythingModal } from '../use-goto-anything-modal'
import { GOTO_ANYTHING_OPEN_EVENT, useGotoAnythingModal } from '../use-goto-anything-modal'
type KeyPressEvent = {
preventDefault: () => void
@ -167,6 +167,20 @@ describe('useGotoAnythingModal', () => {
})
})
describe('open event', () => {
it('should open the modal when the global open event is dispatched', () => {
const { result } = renderHook(() => useGotoAnythingModal())
expect(result.current.show).toBe(false)
act(() => {
window.dispatchEvent(new Event(GOTO_ANYTHING_OPEN_EVENT))
})
expect(result.current.show).toBe(true)
})
})
describe('setShow', () => {
it('should accept boolean value', () => {
const { result } = renderHook(() => useGotoAnythingModal())

View File

@ -1,4 +1,4 @@
export { useGotoAnythingModal } from './use-goto-anything-modal'
export { GOTO_ANYTHING_OPEN_EVENT, useGotoAnythingModal } from './use-goto-anything-modal'
export { useGotoAnythingNavigation } from './use-goto-anything-navigation'

View File

@ -12,6 +12,8 @@ type UseGotoAnythingModalReturn = {
handleClose: () => void
}
export const GOTO_ANYTHING_OPEN_EVENT = 'dify:goto-anything-open'
export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => {
const [show, setShow] = useState<boolean>(false)
const inputRef = useRef<HTMLInputElement>(null)
@ -37,6 +39,13 @@ export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => {
}
})
useEffect(() => {
const handleOpen = () => setShow(true)
window.addEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen)
return () => window.removeEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen)
}, [])
const handleClose = useCallback(() => {
setShow(false)
}, [])

View File

@ -1,6 +1,6 @@
'use client'
import type { MouseEventHandler, ReactNode } from 'react'
import type { MouseEventHandler, ReactElement, ReactNode } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
@ -107,7 +107,16 @@ function AccountMenuSection({ children }: AccountMenuSectionProps) {
return <DropdownMenuGroup className="py-1">{children}</DropdownMenuGroup>
}
export default function AppSelector() {
type AccountDropdownProps = {
trigger?: (props: {
isOpen: boolean
ariaLabel: string
}) => ReactElement
}
export default function AppSelector({
trigger,
}: AccountDropdownProps = {}) {
const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
@ -137,12 +146,23 @@ export default function AppSelector() {
return (
<div>
<DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}>
<DropdownMenuTrigger
aria-label={t('account.account', { ns: 'common' })}
className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
</DropdownMenuTrigger>
{trigger
? (
<DropdownMenuTrigger
render={trigger({
isOpen: isAccountMenuOpen,
ariaLabel: t('account.account', { ns: 'common' }),
})}
/>
)
: (
<DropdownMenuTrigger
aria-label={t('account.account', { ns: 'common' })}
className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
</DropdownMenuTrigger>
)}
<DropdownMenuContent
sideOffset={6}
popupClassName="w-60 max-w-80 bg-components-panel-bg-blur! py-0! backdrop-blur-xs"

View File

@ -0,0 +1,286 @@
import type { Mock } from 'vitest'
import type { AppContextValue } from '@/context/app-context'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { InstalledApp } from '@/models/explore'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { Plan } from '@/app/components/billing/type'
import { GOTO_ANYTHING_OPEN_EVENT } from '@/app/components/goto-anything/hooks'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import { usePathname, useRouter } from '@/next/navigation'
import { switchWorkspace } from '@/service/common'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import MainNav from '../index'
const { mockToastSuccess } = vi.hoisted(() => ({
mockToastSuccess: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/context/workspace-context', () => ({
useWorkspacesContext: vi.fn(),
}))
vi.mock('@/next/navigation', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/next/navigation')>()
return {
...actual,
usePathname: vi.fn(),
useRouter: vi.fn(),
}
})
vi.mock('@/service/common', () => ({
switchWorkspace: vi.fn(),
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: vi.fn(),
useUninstallApp: vi.fn(),
useUpdateAppPinStatus: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
success: mockToastSuccess,
},
}
})
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
const mockPush = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockPathname = '/apps'
let mockInstalledApps: InstalledApp[] = []
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
id: overrides.id ?? 'installed-1',
uninstallable: overrides.uninstallable ?? false,
is_pinned: overrides.is_pinned ?? false,
app: {
id: overrides.app?.id ?? 'app-1',
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '🤖',
icon_background: overrides.app?.icon_background ?? '#fff',
icon_url: overrides.app?.icon_url ?? '',
name: overrides.app?.name ?? 'Alpha App',
description: overrides.app?.description ?? '',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
},
})
const appContextValue: AppContextValue = {
userProfile: {
id: 'user-1',
name: 'Evan Z',
email: 'evan@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
mutateUserProfile: vi.fn(),
currentWorkspace: {
id: 'workspace-1',
name: 'Solar Studio',
plan: Plan.sandbox,
status: 'normal',
created_at: 0,
role: 'owner',
providers: [],
trial_credits: 10000,
trial_credits_used: 2500,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: {
current_env: 'testing',
current_version: '1.0.0',
latest_version: '1.0.0',
release_date: '',
release_notes: '',
version: '1.0.0',
can_auto_update: false,
},
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
const renderMainNav = () => renderWithSystemFeatures(<MainNav />, {
systemFeatures: { branding: { enabled: false } },
})
describe('MainNav', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/apps'
mockInstalledApps = []
;(usePathname as Mock).mockImplementation(() => mockPathname)
;(useRouter as Mock).mockReturnValue({
push: mockPush,
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
})
;(useAppContext as Mock).mockReturnValue(appContextValue)
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
isEducationAccount: false,
plan: { type: Plan.sandbox },
} as ProviderContextState)
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
;(useWorkspacesContext as Mock).mockReturnValue({
workspaces: [
{ id: 'workspace-1', name: 'Solar Studio', current: true },
{ id: 'workspace-2', name: 'Evan Workspace', current: false },
],
})
;(useGetInstalledApps as Mock).mockImplementation(() => ({
isPending: false,
data: { installed_apps: mockInstalledApps },
}))
;(useUninstallApp as Mock).mockReturnValue({
mutateAsync: mockUninstall,
isPending: false,
})
;(useUpdateAppPinStatus as Mock).mockReturnValue({
mutateAsync: mockUpdatePinStatus,
})
;(switchWorkspace as Mock).mockReturnValue(new Promise(() => {}))
})
it('renders primary navigation with the planned routes', () => {
renderMainNav()
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/explore/apps')
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/tools')
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins')
})
it('marks the matching primary route active', () => {
mockPathname = '/datasets'
renderMainNav()
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveClass('bg-components-main-nav-nav-button-bg-active')
})
it('dispatches the goto anything open event from the search button', () => {
const handleOpen = vi.fn()
window.addEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen)
renderMainNav()
fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
expect(handleOpen).toHaveBeenCalledTimes(1)
window.removeEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen)
})
it('opens workspace settings, members, provider credits, upgrade, and workspace switching actions', async () => {
renderMainNav()
fireEvent.click(screen.getByText(/common\.mainNav\.workspace\.credits|7,500 credits/))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
fireEvent.click(screen.getByText('billing.upgradeBtn.encourageShort'))
expect(mockSetShowPricingModal).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(await screen.findByText('common.mainNav.workspace.settings'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.BILLING })
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(await screen.findByText('common.mainNav.workspace.inviteMembers'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.MEMBERS })
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(await screen.findByText('Evan Workspace'))
await waitFor(() => {
expect(switchWorkspace).toHaveBeenCalledWith({ url: '/workspaces/switch', body: { tenant_id: 'workspace-2' } })
})
})
it('filters installed web apps and navigates to an installed app', () => {
mockInstalledApps = [
createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: 'Alpha App' } }),
createInstalledApp({ id: 'installed-2', app: { ...createInstalledApp().app, name: 'Beta Tool' } }),
]
renderMainNav()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.search' }))
fireEvent.change(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), {
target: { value: 'beta' },
})
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Beta Tool'))
expect(mockPush).toHaveBeenCalledWith('/explore/installed/installed-2')
})
it('updates pin status and reuses the existing delete confirmation for installed web apps', async () => {
mockInstalledApps = [createInstalledApp()]
mockUninstall.mockResolvedValue(undefined)
mockUpdatePinStatus.mockResolvedValue(undefined)
renderMainNav()
fireEvent.mouseEnter(screen.getByTitle('Alpha App'))
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'installed-1', isPinned: true })
})
fireEvent.mouseEnter(screen.getByTitle('Alpha App'))
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
fireEvent.click(await screen.findByText('common.operation.confirm'))
await waitFor(() => {
expect(mockUninstall).toHaveBeenCalledWith('installed-1')
expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove')
})
})
})

View File

@ -0,0 +1,79 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { MediaType } from '@/hooks/use-breakpoints'
import MainNavLayout from '../layout'
type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType]
let mockMediaType: MediaTypeValue = MediaType.pc
let mockPathname = '/apps'
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => mockMediaType,
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: undefined,
}),
}))
vi.mock('@/context/workspace-context-provider', () => ({
WorkspaceProvider: ({ children }: { children: ReactNode }) => <div data-testid="workspace-provider">{children}</div>,
}))
vi.mock('@/app/components/header', () => ({
default: () => <div data-testid="desktop-header">Header</div>,
}))
vi.mock('@/app/components/header/header-wrapper', () => ({
default: ({ children }: { children: ReactNode }) => <div data-testid="header-wrapper">{children}</div>,
}))
vi.mock('../index', () => ({
default: ({ className }: { className?: string }) => <aside className={className} data-testid="main-nav">MainNav</aside>,
}))
describe('MainNavLayout', () => {
beforeEach(() => {
mockMediaType = MediaType.pc
mockPathname = '/apps'
localStorage.clear()
})
it('renders desktop main nav instead of the desktop header', () => {
render(<MainNavLayout><div>content</div></MainNavLayout>)
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
expect(screen.queryByTestId('desktop-header')).not.toBeInTheDocument()
expect(screen.getByText('content')).toBeInTheDocument()
})
it('keeps the current header on mobile', () => {
mockMediaType = MediaType.mobile
render(<MainNavLayout><div>content</div></MainNavLayout>)
expect(screen.getByTestId('header-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('desktop-header')).toBeInTheDocument()
expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument()
})
it('hides the desktop main nav on fullscreen workflow canvases', () => {
mockPathname = '/apps/app-1/workflow'
localStorage.setItem('workflow-canvas-maximize', 'true')
render(<MainNavLayout><div>content</div></MainNavLayout>)
expect(screen.getByTestId('main-nav')).toHaveClass('hidden')
})
})

View File

@ -0,0 +1,634 @@
'use client'
import type { ReactNode } from 'react'
import type { InstalledApp } from '@/models/explore'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { Plan } from '@/app/components/billing/type'
import ItemOperation from '@/app/components/explore/item-operation'
import { GOTO_ANYTHING_OPEN_EVENT } from '@/app/components/goto-anything/hooks'
import AccountAbout from '@/app/components/header/account-about'
import AccountDropdown from '@/app/components/header/account-dropdown'
import Compliance from '@/app/components/header/account-dropdown/compliance'
import Support from '@/app/components/header/account-dropdown/support'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import GithubStar from '@/app/components/header/github-star'
import Indicator from '@/app/components/header/indicator'
import PlanBadge from '@/app/components/header/plan-badge'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import { env } from '@/env'
import Link from '@/next/link'
import { usePathname, useRouter } from '@/next/navigation'
import { switchWorkspace } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import { basePath } from '@/utils/var'
type MainNavProps = {
className?: string
}
type MainNavItem = {
href: string
label: string
active: (pathname: string) => boolean
icon: string
activeIcon: string
}
const navItemClassName = 'group relative flex h-9 items-center gap-2 rounded-xl p-2 transition-colors'
const activeNavItemClassName = [
'overflow-hidden border border-components-main-nav-glass-edge-reflection-first bg-components-main-nav-nav-button-bg-active',
'bg-[linear-gradient(98deg,var(--color-components-main-nav-glass-surface-first)_0%,var(--color-components-main-nav-glass-surface-middle-1)_18%,var(--color-components-main-nav-glass-surface-middle-2)_59%,var(--color-components-main-nav-glass-surface-end)_100%)]',
'system-md-semibold text-components-main-nav-text-active backdrop-blur-[5px]',
'shadow-[0px_4px_8px_0px_var(--color-components-main-nav-glass-shadow-reflection-glow),0px_12px_16px_-4px_var(--color-shadow-shadow-5),0px_4px_6px_-2px_var(--color-shadow-shadow-1),0px_10px_16px_-4px_var(--color-components-main-nav-glass-shadow-reflection)]',
'before:pointer-events-none before:absolute before:inset-[-1px] before:rounded-xl before:border before:border-components-main-nav-glass-edge-highlight-first before:shadow-[inset_0px_0px_8px_0px_var(--color-components-main-nav-glass-inner-glow)] before:content-[""]',
].join(' ')
const inactiveNavItemClassName = 'system-md-medium bg-components-main-nav-nav-button-bg text-components-main-nav-text hover:bg-state-base-hover hover:text-components-main-nav-text'
const getWorkspaceInitial = (name?: string) => name?.[0]?.toLocaleUpperCase() || '?'
const getRemainingCredits = (total: number, used: number) => Math.max(total - used, 0)
const formatCredits = (value: number) => new Intl.NumberFormat().format(value)
const WorkspaceIcon = ({
name,
className,
}: {
name?: string
className?: string
}) => (
<div className={cn('flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-components-icon-bg-orange-dark-solid text-white shadow-xs', className)}>
<span className="system-md-semibold">{getWorkspaceInitial(name)}</span>
</div>
)
const MenuIcon = ({
className,
}: {
className?: string
}) => (
<span className={cn('flex h-4 w-4 shrink-0 items-center justify-center text-text-tertiary', className)} />
)
const NavIcon = ({
icon,
className,
}: {
icon: string
className?: string
}) => (
<span aria-hidden className={cn(icon, 'h-5 w-5 shrink-0', className)} />
)
const WorkspaceMenuItemContent = ({
icon,
label,
trailing,
}: {
icon: ReactNode
label: ReactNode
trailing?: ReactNode
}) => (
<>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-text-tertiary">{icon}</span>
<span className="min-w-0 grow truncate text-left system-sm-regular text-text-secondary">{label}</span>
{trailing}
</>
)
const WorkspaceCard = () => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
const { workspaces } = useWorkspacesContext()
const { enableBilling, plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const credits = getRemainingCredits(currentWorkspace.trial_credits, currentWorkspace.trial_credits_used)
const isFreePlan = plan.type === Plan.sandbox
const handlePlanClick = () => {
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}
const handleSwitchWorkspace = async (tenant_id: string) => {
try {
if (currentWorkspace.id === tenant_id)
return
await switchWorkspace({ url: '/workspaces/switch', body: { tenant_id } })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
location.assign(`${location.origin}${basePath}`)
}
catch {
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<div
role="button"
tabIndex={0}
className="w-full rounded-xl border border-components-card-border bg-components-card-bg text-left shadow-xs transition-colors hover:bg-components-card-bg-alt"
aria-label={t('mainNav.workspace.openMenu', { ns: 'common' })}
/>
)}
>
<div className="flex items-center gap-2 px-2 py-2">
<WorkspaceIcon name={currentWorkspace.name} />
<div className="min-w-0 grow">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate system-md-semibold text-text-secondary">{currentWorkspace.name}</span>
<PlanBadge plan={(currentWorkspace.plan || plan.type) as Plan} />
</div>
</div>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</div>
<div className="flex items-center border-t border-divider-subtle">
<button
type="button"
className="flex min-w-0 grow items-center gap-1.5 px-3 py-2 text-left system-sm-regular text-text-tertiary hover:text-text-secondary"
onClick={(e) => {
e.stopPropagation()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
<span className="i-custom-vender-main-nav-credits h-4 w-4 shrink-0" aria-hidden />
<span className="truncate">{t('mainNav.workspace.credits', { ns: 'common', count: formatCredits(credits) })}</span>
</button>
{enableBilling && (
<button
type="button"
className="shrink-0 px-3 py-2 system-xs-semibold-uppercase text-text-accent hover:text-text-accent-secondary"
onClick={(e) => {
e.stopPropagation()
handlePlanClick()
}}
>
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</button>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="right-start"
sideOffset={8}
popupClassName="w-[280px] p-1"
>
<DropdownMenuGroup>
<div className="flex items-center gap-2 px-3 py-2">
<WorkspaceIcon name={currentWorkspace.name} />
<div className="min-w-0 grow">
<div className="truncate system-sm-semibold text-text-secondary">{currentWorkspace.name}</div>
<PlanBadge plan={(currentWorkspace.plan || plan.type) as Plan} />
</div>
</div>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })}
>
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-ri-settings-3-line h-4 w-4" />} label={t('mainNav.workspace.settings', { ns: 'common' })} />
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
>
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-ri-team-line h-4 w-4" />} label={t('mainNav.workspace.inviteMembers', { ns: 'common' })} />
</DropdownMenuItem>
</DropdownMenuGroup>
{workspaces.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-3 py-1 system-2xs-medium-uppercase text-text-tertiary">
{t('mainNav.workspace.switchWorkspace', { ns: 'common' })}
</div>
{workspaces.map(workspace => (
<DropdownMenuItem
key={workspace.id}
className="gap-2 px-3"
onClick={() => {
void handleSwitchWorkspace(workspace.id)
}}
>
<WorkspaceMenuItemContent
icon={<WorkspaceIcon name={workspace.name} className="h-5 w-5 rounded-md" />}
label={workspace.name}
trailing={workspace.current ? <span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" /> : undefined}
/>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
const MainNavLink = ({
item,
pathname,
}: {
item: MainNavItem
pathname: string
}) => {
const activated = item.active(pathname)
return (
<Link
href={item.href}
className={cn(
navItemClassName,
activated ? activeNavItemClassName : inactiveNavItemClassName,
)}
>
<NavIcon icon={activated ? item.activeIcon : item.icon} />
<span className={cn('truncate', activated && 'text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]')}>{item.label}</span>
</Link>
)
}
const MainNavSearchButton = () => {
const { t } = useTranslation()
return (
<button
type="button"
aria-label={t('gotoAnything.searchTitle', { ns: 'app' })}
className="flex h-8 items-center gap-1.5 overflow-hidden rounded-[10px] p-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => window.dispatchEvent(new Event(GOTO_ANYTHING_OPEN_EVENT))}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
<span className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">K</span>
</button>
)
}
const WebAppItem = ({
app,
isSelected,
onDelete,
onTogglePin,
}: {
app: InstalledApp
isSelected: boolean
onDelete: (id: string) => void
onTogglePin: () => void
}) => {
const router = useRouter()
const url = `/explore/installed/${app.id}`
const [isHovering, setIsHovering] = useState(false)
return (
<div
className={cn(
'group flex h-6 cursor-pointer items-center justify-between rounded-lg pr-0.5 pl-2 system-sm-regular text-components-main-nav-text transition-colors',
isSelected ? 'bg-state-base-hover text-components-main-nav-text' : 'hover:bg-state-base-hover hover:text-components-main-nav-text',
)}
onClick={() => router.push(url)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
title={app.app.name}
>
<div className="flex min-w-0 grow items-center gap-2">
<AppIcon
size="tiny"
iconType={app.app.icon_type}
icon={app.app.icon}
background={app.app.icon_background}
imageUrl={app.app.icon_url}
/>
<span className="truncate">{app.app.name}</span>
</div>
<div className="h-6 shrink-0" onClick={e => e.stopPropagation()}>
<ItemOperation
isPinned={app.is_pinned}
isItemHovering={isHovering}
togglePin={onTogglePin}
isShowDelete={!app.uninstallable && !isSelected}
onDelete={() => onDelete(app.id)}
/>
</div>
</div>
)
}
const WebAppsSection = () => {
const { t } = useTranslation()
const pathname = usePathname()
const { data, isPending } = useGetInstalledApps()
const installedApps = useMemo(() => data?.installed_apps ?? [], [data?.installed_apps])
const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('')
const [showConfirm, setShowConfirm] = useState(false)
const [currentId, setCurrentId] = useState('')
const filteredApps = useMemo(() => {
const normalizedSearch = searchText.trim().toLowerCase()
if (!normalizedSearch)
return installedApps
return installedApps.filter(item => item.app.name.toLowerCase().includes(normalizedSearch))
}, [installedApps, searchText])
const handleDelete = async () => {
await uninstallApp(currentId)
setShowConfirm(false)
toast.success(t('api.remove', { ns: 'common' }))
}
const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
await updatePinStatus({ appId: id, isPinned })
toast.success(t('api.success', { ns: 'common' }))
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center justify-between px-2 pb-1">
<button
type="button"
className="flex min-w-0 items-center gap-1 text-left system-xs-medium-uppercase text-text-quaternary hover:text-text-tertiary"
onClick={() => setSearchVisible(value => !value)}
>
<span>{t('sidebar.webApps', { ns: 'explore' })}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5 shrink-0" />
</button>
<div className="flex items-center gap-1">
<button
type="button"
aria-label={t('operation.search', { ns: 'common' })}
className={cn('flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', searchVisible && 'bg-state-base-hover text-text-secondary')}
onClick={() => setSearchVisible(value => !value)}
>
<span aria-hidden className="i-ri-search-line h-4 w-4" />
</button>
</div>
</div>
{searchVisible && (
<div className="px-2 pb-2">
<input
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder={t('mainNav.webApps.searchPlaceholder', { ns: 'common' })}
className="h-8 w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 system-sm-regular text-text-secondary outline-none placeholder:text-text-quaternary hover:border-components-input-border-hover focus:border-components-input-border-active"
/>
</div>
)}
<div className="min-h-0 flex-1 space-y-0.5 overflow-x-hidden overflow-y-auto px-2 pb-2">
{isPending && (
<div className="px-2 py-1 system-xs-regular text-components-main-nav-text">{t('loading', { ns: 'common' })}</div>
)}
{!isPending && filteredApps.length === 0 && (
<div className="px-2 py-1 system-xs-regular text-components-main-nav-text">
{searchText ? t('mainNav.webApps.noResults', { ns: 'common' }) : t('sidebar.noApps.title', { ns: 'explore' })}
</div>
)}
{filteredApps.map(app => (
<WebAppItem
key={app.id}
app={app}
isSelected={pathname.endsWith(`/installed/${app.id}`)}
onDelete={(id) => {
setCurrentId(id)
setShowConfirm(true)
}}
onTogglePin={() => {
void handleUpdatePinStatus(app.id, !app.is_pinned)
}}
/>
))}
</div>
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialogContent>
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('sidebar.delete.title', { ns: 'explore' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('sidebar.delete.content', { ns: 'explore' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={isUninstalling}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isUninstalling} disabled={isUninstalling} onClick={handleDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
const HelpMenu = () => {
const { t } = useTranslation()
const docLink = useDocLink()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { langGeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext()
const [aboutVisible, setAboutVisible] = useState(false)
const [open, setOpen] = useState(false)
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('mainNav.help.openMenu', { ns: 'common' })}
className={cn(
'flex items-center justify-center overflow-hidden rounded-full border border-components-card-border bg-components-card-bg p-0.5 text-components-main-nav-text shadow-[0px_0px_0px_1px_var(--color-components-button-button-seam)] transition-colors hover:bg-components-card-bg-alt hover:text-text-accent hover:shadow-[0px_0px_0px_1px_var(--color-components-button-button-seam),0px_1px_2px_0px_var(--color-shadow-shadow-3)]',
open && 'bg-components-card-bg-alt text-text-accent shadow-[0px_0px_0px_1px_var(--color-components-button-button-seam),0px_1px_2px_0px_var(--color-shadow-shadow-3)]',
)}
>
<span aria-hidden className="i-custom-vender-main-nav-help size-6 shrink-0 rounded-full" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="top-end"
sideOffset={8}
popupClassName="w-60 p-1"
>
{!systemFeatures.branding.enabled && (
<>
<DropdownMenuGroup>
<DropdownMenuLinkItem href={docLink('/use-dify/getting-started/introduction')} target="_blank" rel="noopener noreferrer" className="gap-2 px-3">
<MenuIcon className="i-ri-book-open-line" />
<span className="grow system-sm-regular text-text-secondary">{t('userProfile.helpCenter', { ns: 'common' })}</span>
</DropdownMenuLinkItem>
<Support closeAccountDropdown={() => setOpen(false)} />
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLinkItem href="https://roadmap.dify.ai" target="_blank" rel="noopener noreferrer" className="gap-2 px-3">
<MenuIcon className="i-ri-map-2-line" />
<span className="grow system-sm-regular text-text-secondary">{t('userProfile.roadmap', { ns: 'common' })}</span>
</DropdownMenuLinkItem>
<DropdownMenuLinkItem href="https://github.com/langgenius/dify" target="_blank" rel="noopener noreferrer" className="gap-2 px-3">
<MenuIcon className="i-ri-github-line" />
<span className="grow system-sm-regular text-text-secondary">{t('userProfile.github', { ns: 'common' })}</span>
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
<span aria-hidden className="i-ri-star-line size-3 shrink-0 text-text-tertiary" />
<GithubStar className="system-2xs-medium-uppercase text-text-tertiary" />
</div>
</DropdownMenuLinkItem>
{env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
setAboutVisible(true)
setOpen(false)
}}
>
<span aria-hidden className="i-ri-information-2-line h-4 w-4 shrink-0 text-text-tertiary" />
<span className="grow system-sm-regular text-text-secondary">{t('userProfile.about', { ns: 'common' })}</span>
<div className="flex shrink-0 items-center">
<div className="mr-2 system-xs-regular text-text-tertiary">{langGeniusVersionInfo.current_version}</div>
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
</div>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />}
</>
)
}
const MainNav = ({
className,
}: MainNavProps) => {
const { t } = useTranslation()
const pathname = usePathname()
const { userProfile } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const navItems = useMemo<MainNavItem[]>(() => [
{
href: '/explore/apps',
label: t('mainNav.home', { ns: 'common' }),
active: path => path.startsWith('/explore'),
icon: 'i-custom-vender-main-nav-home',
activeIcon: 'i-custom-vender-main-nav-home-active',
},
{
href: '/apps',
label: t('menus.apps', { ns: 'common' }),
active: path => path.startsWith('/apps') || path.startsWith('/app/'),
icon: 'i-custom-vender-main-nav-studio',
activeIcon: 'i-custom-vender-main-nav-studio-active',
},
{
href: '/datasets',
label: t('menus.datasets', { ns: 'common' }),
active: path => path.startsWith('/datasets'),
icon: 'i-custom-vender-main-nav-knowledge',
activeIcon: 'i-custom-vender-main-nav-knowledge-active',
},
{
href: '/tools',
label: t('mainNav.integrations', { ns: 'common' }),
active: path => path.startsWith('/tools'),
icon: 'i-custom-vender-main-nav-integrations',
activeIcon: 'i-custom-vender-main-nav-integrations-active',
},
{
href: '/plugins',
label: t('mainNav.marketplace', { ns: 'common' }),
active: path => path.startsWith('/plugins'),
icon: 'i-custom-vender-main-nav-marketplace',
activeIcon: 'i-custom-vender-main-nav-marketplace-active',
},
], [t])
const renderLogo = () => (
<h1 className="min-w-0">
<Link href="/apps" className="flex h-8 shrink-0 items-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
return (
<aside className={cn('flex h-full w-[240px] shrink-0 flex-col bg-background-body px-2 py-4', className)}>
<div className="mb-5 flex items-center justify-between px-1">
{renderLogo()}
<MainNavSearchButton />
</div>
<WorkspaceCard />
<nav className="mt-6 space-y-1">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname} />
))}
</nav>
<div className="my-4 h-px bg-divider-subtle" />
<WebAppsSection />
<div className="-mx-2 mt-auto flex w-[240px] items-center justify-between bg-gradient-to-b from-background-body-transparent to-background-body to-50% py-3 pr-1 pl-3 backdrop-blur-[2px]">
<AccountDropdown
trigger={({ isOpen, ariaLabel }) => (
<button
type="button"
aria-label={ariaLabel}
className={cn('flex shrink-0 items-center gap-3 rounded-full py-1 pr-4 pl-1 text-left text-components-main-nav-text transition-colors hover:bg-state-base-hover', isOpen && 'bg-state-base-hover')}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="md" className="size-7" />
<span className="system-md-medium whitespace-nowrap">{userProfile.name}</span>
</button>
)}
/>
<HelpMenu />
</div>
</aside>
)
}
export default MainNav

View File

@ -0,0 +1,64 @@
'use client'
import type { ReactNode } from 'react'
import type { EventEmitterValue } from '@/context/event-emitter'
import { cn } from '@langgenius/dify-ui/cn'
import { useState } from 'react'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { WorkspaceProvider } from '@/context/workspace-context-provider'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { usePathname } from '@/next/navigation'
import MainNav from './index'
type MainNavLayoutProps = {
children: ReactNode
}
const MainNavLayout = ({
children,
}: MainNavLayoutProps) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const [hideMainNav, setHideMainNav] = useState(() => (
globalThis.localStorage?.getItem('workflow-canvas-maximize') === 'true'
))
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: EventEmitterValue) => {
if (typeof v !== 'string' && v.type === 'workflow-canvas-maximize' && typeof v.payload === 'boolean')
setHideMainNav(v.payload)
})
if (isMobile) {
return (
<>
<HeaderWrapper>
<Header />
</HeaderWrapper>
{children}
</>
)
}
return (
<div className="flex h-0 min-h-0 grow overflow-hidden bg-background-body">
<WorkspaceProvider>
<MainNav
className={cn(
hideMainNav && (inWorkflowCanvas || isPipelineCanvas) && 'hidden',
)}
/>
</WorkspaceProvider>
<div className="flex min-w-0 grow flex-col overflow-hidden">
{children}
</div>
</div>
)
}
export default MainNavLayout

View File

@ -214,6 +214,17 @@
"license.expiring_plural": "Expiring in {{count}} days",
"license.unlimited": "Unlimited",
"loading": "Loading",
"mainNav.help.openMenu": "Open help menu",
"mainNav.home": "Home",
"mainNav.integrations": "Integrations",
"mainNav.marketplace": "Marketplace",
"mainNav.webApps.noResults": "No web apps found",
"mainNav.webApps.searchPlaceholder": "Search web apps",
"mainNav.workspace.credits": "{{count}} credits",
"mainNav.workspace.inviteMembers": "Invite and manage members",
"mainNav.workspace.openMenu": "Open workspace menu",
"mainNav.workspace.settings": "Settings",
"mainNav.workspace.switchWorkspace": "Switch workspace",
"members.admin": "Admin",
"members.adminTip": "Can build apps & manage team settings",
"members.builder": "Builder",