diff --git a/src/renderer/src/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx index 66e62dca6f..1168e02431 100644 --- a/src/renderer/src/components/Tab/TabContainer.tsx +++ b/src/renderer/src/components/Tab/TabContainer.tsx @@ -1,4 +1,7 @@ import { PlusOutlined } from '@ant-design/icons' +import { TopNavbarOpenedMinappTabs } from '@renderer/components/app/PinnedMinapps' +import { Sortable, useDndReorder } from '@renderer/components/dnd' +import Scrollbar from '@renderer/components/Scrollbar' import { isLinux, isMac, isWin } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useFullscreen } from '@renderer/hooks/useFullscreen' @@ -7,11 +10,12 @@ import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label' import tabsService from '@renderer/services/TabsService' import { useAppDispatch, useAppSelector } from '@renderer/store' import type { Tab } from '@renderer/store/tabs' -import { addTab, removeTab, setActiveTab } from '@renderer/store/tabs' +import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs' import { ThemeMode } from '@renderer/types' import { classNames } from '@renderer/utils' -import { Tooltip } from 'antd' +import { Button, Tooltip } from 'antd' import { + ChevronRight, FileSearch, Folder, Hammer, @@ -28,13 +32,11 @@ import { Terminal, X } from 'lucide-react' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' import styled from 'styled-components' -import { TopNavbarOpenedMinappTabs } from '../app/PinnedMinapps' - interface TabsContainerProps { children: React.ReactNode } @@ -81,6 +83,8 @@ const TabsContainer: React.FC = ({ children }) => { const { settedTheme, toggleTheme } = useTheme() const { hideMinappPopup } = useMinappPopup() const { t } = useTranslation() + const scrollRef = useRef(null) + const [canScroll, setCanScroll] = useState(false) const getTabId = (path: string): string => { if (path === '/') return 'home' @@ -142,34 +146,83 @@ const TabsContainer: React.FC = ({ children }) => { navigate(tab.path) } + const handleScrollRight = () => { + scrollRef.current?.scrollBy({ left: 200, behavior: 'smooth' }) + } + + useEffect(() => { + const scrollElement = scrollRef.current + if (!scrollElement) return + + const checkScrollability = () => { + setCanScroll(scrollElement.scrollWidth > scrollElement.clientWidth) + } + + checkScrollability() + + const resizeObserver = new ResizeObserver(checkScrollability) + resizeObserver.observe(scrollElement) + + window.addEventListener('resize', checkScrollability) + + return () => { + resizeObserver.disconnect() + window.removeEventListener('resize', checkScrollability) + } + }, [tabs]) + + const visibleTabs = useMemo(() => tabs.filter((tab) => !specialTabs.includes(tab.id)), [tabs]) + + const { onSortEnd } = useDndReorder({ + originalList: tabs, + filteredList: visibleTabs, + onUpdate: (newTabs) => dispatch(setTabs(newTabs)), + itemKey: 'id' + }) + return ( - {tabs - .filter((tab) => !specialTabs.includes(tab.id)) - .map((tab) => { - return ( - handleTabClick(tab)}> - - {tab.id && {getTabIcon(tab.id)}} - {getTitleLabel(tab.id)} - - {tab.id !== 'home' && ( - { - e.stopPropagation() - closeTab(tab.id) - }}> - - - )} - - ) - })} - - - + + + ( + handleTabClick(tab)}> + + {tab.id && {getTabIcon(tab.id)}} + {getTitleLabel(tab.id)} + + {tab.id !== 'home' && ( + { + e.stopPropagation() + closeTab(tab.id) + }}> + + + )} + + )} + /> + + {canScroll && ( + + + + )} + + + + ` @@ -221,6 +275,34 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>` } ` +const TabsArea = styled.div` + display: flex; + align-items: center; + flex: 1 1 auto; + min-width: 0; + gap: 6px; + padding-right: 2rem; + position: relative; + + -webkit-app-region: drag; + + > * { + -webkit-app-region: no-drag; + } + + &:hover { + .scroll-right-button { + opacity: 1; + } + } +` + +const TabsScroll = styled(Scrollbar)` + &::-webkit-scrollbar { + display: none; + } +` + const Tab = styled.div<{ active?: boolean }>` display: flex; align-items: center; @@ -228,12 +310,12 @@ const Tab = styled.div<{ active?: boolean }>` padding: 4px 10px; padding-right: 8px; background: ${(props) => (props.active ? 'var(--color-list-item)' : 'transparent')}; + transition: background 0.2s; border-radius: var(--list-item-border-radius); - cursor: pointer; user-select: none; height: 30px; min-width: 90px; - transition: background 0.2s; + .close-button { opacity: 0; transition: opacity 0.2s; @@ -251,12 +333,15 @@ const TabHeader = styled.div` display: flex; align-items: center; gap: 6px; + min-width: 0; + flex: 1; ` const TabIcon = styled.span` display: flex; align-items: center; color: var(--color-text-2); + flex-shrink: 0; ` const TabTitle = styled.span` @@ -265,6 +350,8 @@ const TabTitle = styled.span` display: flex; align-items: center; margin-right: 4px; + overflow: hidden; + white-space: nowrap; ` const CloseButton = styled.span` @@ -284,6 +371,7 @@ const AddTabButton = styled.div` cursor: pointer; color: var(--color-text-2); border-radius: var(--list-item-border-radius); + flex-shrink: 0; &.active { background: var(--color-list-item); } @@ -292,11 +380,28 @@ const AddTabButton = styled.div` } ` +const ScrollButton = styled(Button)` + position: absolute; + right: 4rem; + top: 50%; + transform: translateY(-50%); + z-index: 1; + opacity: 0; + transition: opacity 0.2s ease-in-out; + + border: none; + box-shadow: + 0 6px 16px 0 rgba(0, 0, 0, 0.08), + 0 3px 6px -4px rgba(0, 0, 0, 0.12), + 0 9px 28px 8px rgba(0, 0, 0, 0.05); +` + const RightButtonsContainer = styled.div` display: flex; align-items: center; gap: 6px; margin-left: auto; + flex-shrink: 0; ` const ThemeButton = styled.div` diff --git a/src/renderer/src/components/dnd/Sortable.tsx b/src/renderer/src/components/dnd/Sortable.tsx index a3102748cc..3ef77acb31 100644 --- a/src/renderer/src/components/dnd/Sortable.tsx +++ b/src/renderer/src/components/dnd/Sortable.tsx @@ -56,6 +56,8 @@ interface SortableProps { listStyle?: React.CSSProperties /** Ghost item style */ ghostItemStyle?: React.CSSProperties + /** Item gap */ + gap?: number | string } function Sortable({ @@ -70,7 +72,8 @@ function Sortable({ useDragOverlay = true, showGhost = false, className, - listStyle + listStyle, + gap }: SortableProps) { const sensors = useSensors( useSensor(PortalSafePointerSensor, { @@ -150,7 +153,12 @@ function Sortable({ onDragCancel={handleDragCancel} modifiers={modifiers}> - + {items.map((item, index) => ( ({ ) } -const ListWrapper = styled.div` +const ListWrapper = styled.div<{ $gap?: number | string }>` + gap: ${({ $gap }) => $gap}; + &[data-layout='grid'] { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); width: 100%; - gap: 12px; @media (max-width: 768px) { grid-template-columns: 1fr; } } + + &[data-layout='list'] { + display: flex; + align-items: center; + + [data-direction='horizontal'] { + flex-direction: row; + } + + [data-direction='vertical'] { + flex-direction: column; + } + } ` export default Sortable diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index 5f75c36c0a..4fa1568954 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -14,7 +14,7 @@ import { setNarrowMode } from '@renderer/store/settings' import { Assistant, Topic } from '@renderer/types' import { Tooltip } from 'antd' import { t } from 'i18next' -import { Menu, MessageSquareDiff, PanelLeftClose, PanelRightClose, Search } from 'lucide-react' +import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { FC } from 'react' import styled from 'styled-components' @@ -83,11 +83,6 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo - - EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} style={{ marginRight: 5 }}> - - - )} diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index bbd108a26b..bb78b49db7 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -251,6 +251,7 @@ const McpServersList: FC = () => { itemKey="id" onSortEnd={onSortEnd} layout="grid" + gap={'12px'} useDragOverlay showGhost renderItem={(server) => ( diff --git a/src/renderer/src/services/TabsService.ts b/src/renderer/src/services/TabsService.ts index bce9fa376f..0153dd5663 100644 --- a/src/renderer/src/services/TabsService.ts +++ b/src/renderer/src/services/TabsService.ts @@ -34,12 +34,18 @@ class TabsService { const remainingTabs = tabs.filter((tab) => tab.id !== tabId) const lastTab = remainingTabs[remainingTabs.length - 1] + store.dispatch(setActiveTab(lastTab.id)) + // 使用 NavigationService 导航到新的标签页 if (NavigationService.navigate) { NavigationService.navigate(lastTab.path) } else { - logger.error('Navigation service is not initialized') - return false + logger.warn('Navigation service not ready, will navigate on next render') + setTimeout(() => { + if (NavigationService.navigate) { + NavigationService.navigate(lastTab.path) + } + }, 100) } } diff --git a/src/renderer/src/store/tabs.ts b/src/renderer/src/store/tabs.ts index 01dc7b1fb3..16195cd5f2 100644 --- a/src/renderer/src/store/tabs.ts +++ b/src/renderer/src/store/tabs.ts @@ -24,6 +24,9 @@ const tabsSlice = createSlice({ name: 'tabs', initialState, reducers: { + setTabs: (state, action: PayloadAction) => { + state.tabs = action.payload + }, addTab: (state, action: PayloadAction) => { const existingTab = state.tabs.find((tab) => tab.path === action.payload.path) if (!existingTab) { @@ -53,5 +56,5 @@ const tabsSlice = createSlice({ } }) -export const { addTab, removeTab, setActiveTab, updateTab } = tabsSlice.actions +export const { setTabs, addTab, removeTab, setActiveTab, updateTab } = tabsSlice.actions export default tabsSlice.reducer