mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-02-10 12:53:24 +08:00
feat: new dnd list (#9311)
* feat: add Sortable * refactor: update SortableItem style, fix grid layout * refactor: dragOverlay * refactor: use Sortable grid in mcp server list * refactor: improve style * refactor: support custom dropAnimation for drag overlay * fix: cursor grabbing * fix: unexpected drag * fix: z-index * revert: assistants tab * refactor: improve button layout * docs: update comments * fix: interaction between Sortable and portal elements * refactor: improve McpServerCard dnd experience * refactor: prevent pointer events on drag overlay * refactor: rename and extraction * refactor: simplify usage * refactor: add showGhost
This commit is contained in:
parent
cce88745c2
commit
070614cd3c
@ -103,6 +103,10 @@
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
|
||||
110
src/renderer/src/components/dnd/ItemRenderer.tsx
Normal file
110
src/renderer/src/components/dnd/ItemRenderer.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { DraggableSyntheticListeners } from '@dnd-kit/core'
|
||||
import { Transform } from '@dnd-kit/utilities'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import React, { useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ItemRendererProps<T> {
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
item: T
|
||||
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
|
||||
dragging?: boolean
|
||||
dragOverlay?: boolean
|
||||
ghost?: boolean
|
||||
transform?: Transform | null
|
||||
transition?: string | null
|
||||
listeners?: DraggableSyntheticListeners
|
||||
}
|
||||
|
||||
export function ItemRenderer<T>({
|
||||
ref,
|
||||
item,
|
||||
renderItem,
|
||||
dragging,
|
||||
dragOverlay,
|
||||
ghost,
|
||||
transform,
|
||||
transition,
|
||||
listeners,
|
||||
...props
|
||||
}: ItemRendererProps<T>) {
|
||||
useEffect(() => {
|
||||
if (!dragOverlay) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'grabbing'
|
||||
|
||||
return () => {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}, [dragOverlay])
|
||||
|
||||
const wrapperStyle = {
|
||||
transition,
|
||||
'--translate-x': transform ? `${Math.round(transform.x)}px` : undefined,
|
||||
'--translate-y': transform ? `${Math.round(transform.y)}px` : undefined,
|
||||
'--scale-x': transform?.scaleX ? `${transform.scaleX}` : undefined,
|
||||
'--scale-y': transform?.scaleY ? `${transform.scaleY}` : undefined
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<ItemWrapper ref={ref} className={classNames({ dragOverlay: dragOverlay })} style={{ ...wrapperStyle }}>
|
||||
<DraggableItem
|
||||
className={classNames({ dragging: dragging, dragOverlay: dragOverlay, ghost: ghost })}
|
||||
{...listeners}
|
||||
{...props}>
|
||||
{renderItem(item, { dragging: !!dragging })}
|
||||
</DraggableItem>
|
||||
</ItemWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemWrapper = styled.div`
|
||||
box-sizing: border-box;
|
||||
transform: translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) scaleX(var(--scale-x, 1))
|
||||
scaleY(var(--scale-y, 1));
|
||||
transform-origin: 0 0;
|
||||
touch-action: manipulation;
|
||||
|
||||
&.dragOverlay {
|
||||
--scale: 1.02;
|
||||
z-index: 999;
|
||||
position: relative;
|
||||
}
|
||||
`
|
||||
|
||||
const DraggableItem = styled.div`
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer; /* default cursor for items */
|
||||
touch-action: manipulation;
|
||||
transform-origin: 50% 50%;
|
||||
transform: scale(var(--scale, 1));
|
||||
|
||||
&.dragging:not(.dragOverlay) {
|
||||
z-index: 0;
|
||||
opacity: 0.25;
|
||||
|
||||
&:not(.ghost) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.dragOverlay {
|
||||
cursor: inherit;
|
||||
animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22);
|
||||
transform: scale(var(--scale));
|
||||
opacity: 1;
|
||||
pointer-events: none; /* prevent pointer events on drag overlay */
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(var(--scale));
|
||||
}
|
||||
}
|
||||
`
|
||||
192
src/renderer/src/components/dnd/Sortable.tsx
Normal file
192
src/renderer/src/components/dnd/Sortable.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import {
|
||||
Active,
|
||||
defaultDropAnimationSideEffects,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
DropAnimation,
|
||||
KeyboardSensor,
|
||||
Over,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import { restrictToHorizontalAxis, restrictToVerticalAxis } from '@dnd-kit/modifiers'
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { ItemRenderer } from './ItemRenderer'
|
||||
import { SortableItem } from './SortableItem'
|
||||
import { PortalSafePointerSensor } from './utils'
|
||||
|
||||
interface SortableProps<T> {
|
||||
/** Array of sortable items */
|
||||
items: T[]
|
||||
/** Function or key to get unique identifier for each item */
|
||||
itemKey: keyof T | ((item: T) => string | number)
|
||||
/** Callback when sorting is complete, receives old and new indices */
|
||||
onSortEnd: (event: { oldIndex: number; newIndex: number }) => void
|
||||
/** Callback when drag starts, will be passed to dnd-kit's onDragStart */
|
||||
onDragStart?: (event: { active: Active }) => void
|
||||
/** Callback when drag ends, will be passed to dnd-kit's onDragEnd */
|
||||
onDragEnd?: (event: { over: Over }) => void
|
||||
/** Function to render individual item, receives item data and drag state */
|
||||
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
|
||||
/** Layout type - 'list' for vertical/horizontal list, 'grid' for grid layout */
|
||||
layout?: 'list' | 'grid'
|
||||
/** Whether sorting is horizontal */
|
||||
horizontal?: boolean
|
||||
/** Whether to use drag overlay
|
||||
* If you want to hide ghost item, set showGhost to false rather than useDragOverlay.
|
||||
*/
|
||||
useDragOverlay?: boolean
|
||||
/** Whether to show ghost item, only works when useDragOverlay is true */
|
||||
showGhost?: boolean
|
||||
/** Item list class name */
|
||||
className?: string
|
||||
/** Item list style */
|
||||
listStyle?: React.CSSProperties
|
||||
/** Ghost item style */
|
||||
ghostItemStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
function Sortable<T>({
|
||||
items,
|
||||
itemKey,
|
||||
onSortEnd,
|
||||
onDragStart: customOnDragStart,
|
||||
onDragEnd: customOnDragEnd,
|
||||
renderItem,
|
||||
layout = 'list',
|
||||
horizontal = false,
|
||||
useDragOverlay = true,
|
||||
showGhost = false,
|
||||
className,
|
||||
listStyle
|
||||
}: SortableProps<T>) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PortalSafePointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8
|
||||
}
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 5
|
||||
}
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
)
|
||||
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as string | number)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
const itemIds = useMemo(() => items.map(getId), [items, getId])
|
||||
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
|
||||
|
||||
const activeItem = activeId ? items.find((item) => getId(item) === activeId) : null
|
||||
|
||||
const getIndex = (id: UniqueIdentifier) => itemIds.indexOf(id)
|
||||
|
||||
const activeIndex = activeId ? getIndex(activeId) : -1
|
||||
|
||||
const handleDragStart = ({ active }) => {
|
||||
customOnDragStart?.({ active })
|
||||
if (active) {
|
||||
setActiveId(active.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = ({ over }) => {
|
||||
setActiveId(null)
|
||||
|
||||
customOnDragEnd?.({ over })
|
||||
if (over) {
|
||||
const overIndex = getIndex(over.id)
|
||||
if (activeIndex !== overIndex) {
|
||||
onSortEnd({ oldIndex: activeIndex, newIndex: overIndex })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null)
|
||||
}
|
||||
|
||||
const strategy =
|
||||
layout === 'list' ? (horizontal ? horizontalListSortingStrategy : verticalListSortingStrategy) : rectSortingStrategy
|
||||
const modifiers = layout === 'list' ? (horizontal ? [restrictToHorizontalAxis] : [restrictToVerticalAxis]) : []
|
||||
|
||||
const dropAnimation: DropAnimation = useMemo(
|
||||
() => ({
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: { opacity: showGhost ? '0.25' : '0' }
|
||||
}
|
||||
})
|
||||
}),
|
||||
[showGhost]
|
||||
)
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
modifiers={modifiers}>
|
||||
<SortableContext items={itemIds} strategy={strategy}>
|
||||
<ListWrapper className={className} data-layout={layout} style={listStyle}>
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={itemIds[index]}
|
||||
item={item}
|
||||
getId={getId}
|
||||
renderItem={renderItem}
|
||||
useDragOverlay={useDragOverlay}
|
||||
showGhost={showGhost}
|
||||
/>
|
||||
))}
|
||||
</ListWrapper>
|
||||
</SortableContext>
|
||||
|
||||
{useDragOverlay
|
||||
? createPortal(
|
||||
<DragOverlay adjustScale dropAnimation={dropAnimation}>
|
||||
{activeItem ? <ItemRenderer item={activeItem} renderItem={renderItem} dragOverlay /> : null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
const ListWrapper = styled.div`
|
||||
&[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;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default Sortable
|
||||
41
src/renderer/src/components/dnd/SortableItem.tsx
Normal file
41
src/renderer/src/components/dnd/SortableItem.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import React from 'react'
|
||||
|
||||
import { ItemRenderer } from './ItemRenderer'
|
||||
|
||||
interface SortableItemProps<T> {
|
||||
item: T
|
||||
getId: (item: T) => string | number
|
||||
renderItem: (item: T, props: { dragging: boolean }) => React.ReactNode
|
||||
useDragOverlay?: boolean
|
||||
showGhost?: boolean
|
||||
}
|
||||
|
||||
export function SortableItem<T>({
|
||||
item,
|
||||
getId,
|
||||
renderItem,
|
||||
useDragOverlay = true,
|
||||
showGhost = true
|
||||
}: SortableItemProps<T>) {
|
||||
const id = getId(item)
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id
|
||||
})
|
||||
|
||||
return (
|
||||
<ItemRenderer
|
||||
ref={setNodeRef}
|
||||
item={item}
|
||||
renderItem={renderItem}
|
||||
dragging={isDragging}
|
||||
dragOverlay={!useDragOverlay && isDragging}
|
||||
ghost={showGhost && useDragOverlay && isDragging}
|
||||
transform={transform}
|
||||
transition={transition}
|
||||
listeners={listeners}
|
||||
{...attributes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
3
src/renderer/src/components/dnd/index.ts
Normal file
3
src/renderer/src/components/dnd/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as Sortable } from './Sortable'
|
||||
export * from './useDndReorder'
|
||||
export * from './useDndState'
|
||||
74
src/renderer/src/components/dnd/useDndReorder.ts
Normal file
74
src/renderer/src/components/dnd/useDndReorder.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { Key, useCallback, useMemo } from 'react'
|
||||
|
||||
interface UseDndReorderParams<T> {
|
||||
/** 原始的、完整的数据列表 */
|
||||
originalList: T[]
|
||||
/** 当前在界面上渲染的、可能被过滤的列表 */
|
||||
filteredList: T[]
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
idKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 增强拖拽排序能力,处理“过滤后列表”与“原始列表”的索引映射问题。
|
||||
*
|
||||
* @template T 列表项的类型
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 Sortable 的 onSortEnd 回调
|
||||
*/
|
||||
export function useDndReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDndReorderParams<T>) {
|
||||
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
const map = new Map<Key, number>()
|
||||
originalList.forEach((item, index) => {
|
||||
map.set(getId(item), index)
|
||||
})
|
||||
return map
|
||||
}, [originalList, getId])
|
||||
|
||||
// 创建一个函数,将 *过滤后列表* 的视图索引转换为 *原始列表* 的数据索引
|
||||
const getItemKey = useCallback(
|
||||
(index: number): Key => {
|
||||
const item = filteredList[index]
|
||||
// 如果找不到item,返回视图索引兜底
|
||||
if (!item) return index
|
||||
|
||||
const originalIndex = itemIndexMap.get(getId(item))
|
||||
return originalIndex ?? index
|
||||
},
|
||||
[filteredList, itemIndexMap, getId]
|
||||
)
|
||||
|
||||
// 创建 onSortEnd 回调,封装了所有重排逻辑
|
||||
const onSortEnd = useCallback(
|
||||
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
|
||||
// 使用 getItemKey 将视图索引转换为数据索引
|
||||
const sourceOriginalIndex = getItemKey(oldIndex) as number
|
||||
const destOriginalIndex = getItemKey(newIndex) as number
|
||||
|
||||
// 如果索引转换失败,不进行任何操作
|
||||
if (sourceOriginalIndex === undefined || destOriginalIndex === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sourceOriginalIndex === destOriginalIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
// 操作原始列表的副本
|
||||
const newList = [...originalList]
|
||||
const [movedItem] = newList.splice(sourceOriginalIndex, 1)
|
||||
newList.splice(destOriginalIndex, 0, movedItem)
|
||||
|
||||
// 调用外部更新函数
|
||||
onUpdate(newList)
|
||||
},
|
||||
[getItemKey, originalList, onUpdate]
|
||||
)
|
||||
|
||||
return { onSortEnd, itemKey: getItemKey }
|
||||
}
|
||||
28
src/renderer/src/components/dnd/useDndState.ts
Normal file
28
src/renderer/src/components/dnd/useDndState.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useDndContext } from '@dnd-kit/core'
|
||||
|
||||
interface DndState {
|
||||
/** 是否有元素正在拖拽 */
|
||||
isDragging: boolean
|
||||
/** 当前拖拽元素的ID */
|
||||
draggedId: string | number | null
|
||||
/** 当前悬停位置的ID */
|
||||
overId: string | number | null
|
||||
/** 是否正在悬停在某个可放置区域 */
|
||||
isOver: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供 dnd-kit 的全局拖拽状态管理
|
||||
*
|
||||
* @returns 当前拖拽状态信息
|
||||
*/
|
||||
export function useDndState(): DndState {
|
||||
const { active, over } = useDndContext()
|
||||
|
||||
return {
|
||||
isDragging: active !== null,
|
||||
draggedId: active?.id ?? null,
|
||||
overId: over?.id ?? null,
|
||||
isOver: over !== null
|
||||
}
|
||||
}
|
||||
45
src/renderer/src/components/dnd/utils.ts
Normal file
45
src/renderer/src/components/dnd/utils.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { defaultDropAnimationSideEffects, type DropAnimation, PointerSensor } from '@dnd-kit/core'
|
||||
|
||||
export const PORTAL_NO_DND_SELECTORS = [
|
||||
'.ant-dropdown',
|
||||
'.ant-select-dropdown',
|
||||
'.ant-popover',
|
||||
'.ant-tooltip',
|
||||
'.ant-modal'
|
||||
].join(',')
|
||||
|
||||
/**
|
||||
* Default drop animation config.
|
||||
* The opacity is set so to match the drag overlay case.
|
||||
*/
|
||||
export const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: '0.25'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent drag on elements with specific classes or data-no-dnd attribute
|
||||
*/
|
||||
export class PortalSafePointerSensor extends PointerSensor {
|
||||
static activators = [
|
||||
{
|
||||
eventName: 'onPointerDown',
|
||||
handler: ({ nativeEvent: event }) => {
|
||||
let target = event.target as HTMLElement
|
||||
|
||||
while (target) {
|
||||
if (target.closest(PORTAL_NO_DND_SELECTORS) || target.dataset?.noDnd) {
|
||||
return false
|
||||
}
|
||||
target = target.parentElement as HTMLElement
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
] as (typeof PointerSensor)['activators']
|
||||
}
|
||||
@ -137,7 +137,10 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['contextMenu']}
|
||||
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
|
||||
<Container onClick={handleSwitch} className={isActive ? 'active' : ''}>
|
||||
<AssistantNameRow className="name" title={fullAssistantName}>
|
||||
{assistantIconType === 'model' ? (
|
||||
@ -386,7 +389,6 @@ const Container = styled.div`
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid transparent;
|
||||
width: calc(var(--assistants-width) - 20px);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--color-list-item-hover);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
import { getMcpTypeLabel } from '@renderer/i18n/label'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Badge, Button, Switch, Tag } from 'antd'
|
||||
import { Button, Switch, Tag, Typography } from 'antd'
|
||||
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@ -33,38 +33,49 @@ const McpServerCard: FC<McpServerCardProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContainer $isActive={server.isActive}>
|
||||
<CardContainer $isActive={server.isActive} onClick={onEdit}>
|
||||
<ServerHeader>
|
||||
<ServerName>
|
||||
<ServerNameWrapper>
|
||||
{server.logoUrl && <ServerLogo src={server.logoUrl} alt={`${server.name} logo`} />}
|
||||
<ServerNameText>{server.name}</ServerNameText>
|
||||
{version && <VersionBadge count={version} color="blue" />}
|
||||
<ServerNameText ellipsis={{ tooltip: true }}>{server.name}</ServerNameText>
|
||||
{server.providerUrl && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<SquareArrowOutUpRight size={14} />}
|
||||
className="nodrag"
|
||||
onClick={handleOpenUrl}
|
||||
data-no-dnd
|
||||
/>
|
||||
)}
|
||||
</ServerName>
|
||||
</ServerNameWrapper>
|
||||
<ToolbarWrapper onClick={(e) => e.stopPropagation()}>
|
||||
<Switch value={server.isActive} key={server.id} loading={isLoading} onChange={onToggle} size="small" />
|
||||
<Switch
|
||||
value={server.isActive}
|
||||
key={server.id}
|
||||
loading={isLoading}
|
||||
onChange={onToggle}
|
||||
size="small"
|
||||
data-no-dnd
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<DeleteIcon size={16} className="lucide-custom" />}
|
||||
className="nodrag"
|
||||
icon={<DeleteIcon size={14} className="lucide-custom" />}
|
||||
danger
|
||||
onClick={onDelete}
|
||||
data-no-dnd
|
||||
/>
|
||||
<Button type="text" shape="circle" icon={<Settings2 size={16} />} className="nodrag" onClick={onEdit} />
|
||||
<Button type="text" shape="circle" icon={<Settings2 size={14} />} onClick={onEdit} data-no-dnd />
|
||||
</ToolbarWrapper>
|
||||
</ServerHeader>
|
||||
<ServerDescription>{server.description}</ServerDescription>
|
||||
<ServerFooter>
|
||||
{version && (
|
||||
<VersionBadge color="#108ee9">
|
||||
<VersionText ellipsis={{ tooltip: true }}>{version}</VersionText>
|
||||
</VersionBadge>
|
||||
)}
|
||||
<ServerTag color="processing">{getMcpTypeLabel(server.type ?? 'stdio')}</ServerTag>
|
||||
{server.provider && <ServerTag color="success">{server.provider}</ServerTag>}
|
||||
{server.tags
|
||||
@ -85,17 +96,17 @@ const CardContainer = styled.div<{ $isActive: boolean }>`
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: var(--list-item-border-radius);
|
||||
padding: 10px 16px;
|
||||
padding: 10px 10px 10px 16px;
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--color-background);
|
||||
margin-bottom: 5px;
|
||||
height: 125px;
|
||||
cursor: pointer;
|
||||
opacity: ${(props) => (props.$isActive ? 1 : 0.6)};
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`
|
||||
|
||||
@ -105,17 +116,16 @@ const ServerHeader = styled.div`
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
|
||||
const ServerName = styled.div`
|
||||
const ServerNameWrapper = styled.div`
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const ServerNameText = styled.span`
|
||||
const ServerNameText = styled(Typography.Text)`
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
`
|
||||
@ -131,7 +141,7 @@ const ServerLogo = styled.img`
|
||||
const ToolbarWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
|
||||
> :first-child {
|
||||
margin-right: 4px;
|
||||
@ -163,19 +173,14 @@ const ServerTag = styled(Tag)`
|
||||
margin: 0;
|
||||
`
|
||||
|
||||
const VersionBadge = styled(Badge)`
|
||||
.ant-badge-count {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 0 5px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 8px;
|
||||
min-width: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
const VersionBadge = styled(ServerTag)`
|
||||
font-weight: 500;
|
||||
max-width: 6rem !important;
|
||||
`
|
||||
|
||||
const VersionText = styled(Typography.Text)`
|
||||
font-size: inherit;
|
||||
color: white;
|
||||
`
|
||||
|
||||
export default McpServerCard
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import { Sortable } from '@renderer/components/dnd'
|
||||
import { EditIcon, RefreshIcon } from '@renderer/components/Icons'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
@ -192,53 +192,57 @@ const McpServersList: FC = () => {
|
||||
<ListHeader>
|
||||
<SettingTitle style={{ gap: 3 }}>
|
||||
<span>{t('settings.mcp.newServer')}</span>
|
||||
<Button icon={<EditIcon size={14} />} type="text" onClick={() => EditMcpJsonPopup.show()} shape="circle" />
|
||||
</SettingTitle>
|
||||
<ButtonGroup>
|
||||
<InstallNpxUv mini />
|
||||
<Button icon={<EditIcon size={14} />} type="default" shape="round" onClick={() => EditMcpJsonPopup.show()}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems
|
||||
}}
|
||||
trigger={['click']}>
|
||||
<Button icon={<Plus size={16} />} type="default" shape="round">
|
||||
{t('settings.mcp.addServer.label')}
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button icon={<RefreshIcon size={16} />} type="default" onClick={onSyncServers} shape="round">
|
||||
{t('settings.mcp.sync.title', 'Sync Servers')}
|
||||
<Button icon={<RefreshIcon size={14} />} type="default" onClick={onSyncServers} shape="round">
|
||||
{t('settings.mcp.sync.button')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ListHeader>
|
||||
<DraggableList
|
||||
style={{ width: '100%' }}
|
||||
list={mcpServers}
|
||||
onUpdate={updateMcpServers}
|
||||
listProps={{
|
||||
locale: {
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('settings.mcp.noServers')}
|
||||
style={{ marginTop: 20 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}>
|
||||
{(server: MCPServer) => (
|
||||
<div onClick={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}>
|
||||
<McpServerCard
|
||||
server={server}
|
||||
version={serverVersions[server.id]}
|
||||
isLoading={loadingServerIds.has(server.id)}
|
||||
onToggle={(active) => handleToggleActive(server, active)}
|
||||
onDelete={() => onDeleteMcpServer(server)}
|
||||
onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}
|
||||
onOpenUrl={(url) => window.open(url, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
<Sortable
|
||||
items={mcpServers}
|
||||
itemKey="id"
|
||||
onSortEnd={({ oldIndex, newIndex }) => {
|
||||
const newList = [...mcpServers]
|
||||
const [removed] = newList.splice(oldIndex, 1)
|
||||
newList.splice(newIndex, 0, removed)
|
||||
updateMcpServers(newList)
|
||||
}}
|
||||
layout="grid"
|
||||
useDragOverlay
|
||||
showGhost
|
||||
renderItem={(server) => (
|
||||
<McpServerCard
|
||||
server={server}
|
||||
version={serverVersions[server.id]}
|
||||
isLoading={loadingServerIds.has(server.id)}
|
||||
onToggle={(active) => handleToggleActive(server, active)}
|
||||
onDelete={() => onDeleteMcpServer(server)}
|
||||
onEdit={() => navigate(`/settings/mcp/settings/${encodeURIComponent(server.id)}`)}
|
||||
onOpenUrl={(url) => window.open(url, '_blank')}
|
||||
/>
|
||||
)}
|
||||
</DraggableList>
|
||||
/>
|
||||
{mcpServers.length === 0 && (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('settings.mcp.noServers')}
|
||||
style={{ marginTop: 20 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<McpMarketList />
|
||||
<BuiltinMCPServerList />
|
||||
|
||||
68
yarn.lock
68
yarn.lock
@ -2691,6 +2691,68 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/accessibility@npm:^3.1.1":
|
||||
version: 3.1.1
|
||||
resolution: "@dnd-kit/accessibility@npm:3.1.1"
|
||||
dependencies:
|
||||
tslib: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
checksum: 10c0/be0bf41716dc58f9386bc36906ec1ce72b7b42b6d1d0e631d347afe9bd8714a829bd6f58a346dd089b1519e93918ae2f94497411a61a4f5e4d9247c6cfd1fef8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/core@npm:^6.3.1":
|
||||
version: 6.3.1
|
||||
resolution: "@dnd-kit/core@npm:6.3.1"
|
||||
dependencies:
|
||||
"@dnd-kit/accessibility": "npm:^3.1.1"
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
tslib: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 10c0/196db95d81096d9dc248983533eab91ba83591770fa5c894b1ac776f42af0d99522b3fd5bb3923411470e4733fcfa103e6ee17adc17b9b7eb54c7fbec5ff7c52
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/modifiers@npm:^9.0.0":
|
||||
version: 9.0.0
|
||||
resolution: "@dnd-kit/modifiers@npm:9.0.0"
|
||||
dependencies:
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
tslib: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
"@dnd-kit/core": ^6.3.0
|
||||
react: ">=16.8.0"
|
||||
checksum: 10c0/ca8cc9da8296df10774d779c1611074dc327ccc3c49041c102111c98c7f2b2b73b6af5209c0eef6b2fe978ac63dc2a985efa87c85a8d786577304bd2e64cee1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/sortable@npm:^10.0.0":
|
||||
version: 10.0.0
|
||||
resolution: "@dnd-kit/sortable@npm:10.0.0"
|
||||
dependencies:
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
tslib: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
"@dnd-kit/core": ^6.3.0
|
||||
react: ">=16.8.0"
|
||||
checksum: 10c0/37ee48bc6789fb512dc0e4c374a96d19abe5b2b76dc34856a5883aaa96c3297891b94cc77bbc409e074dcce70967ebcb9feb40cd9abadb8716fc280b4c7f99af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dnd-kit/utilities@npm:^3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "@dnd-kit/utilities@npm:3.2.2"
|
||||
dependencies:
|
||||
tslib: "npm:^2.0.0"
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
checksum: 10c0/9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@electron-toolkit/eslint-config-prettier@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@electron-toolkit/eslint-config-prettier@npm:3.0.0"
|
||||
@ -8406,6 +8468,10 @@ __metadata:
|
||||
"@cherrystudio/embedjs-loader-xml": "npm:^0.1.31"
|
||||
"@cherrystudio/embedjs-ollama": "npm:^0.1.31"
|
||||
"@cherrystudio/embedjs-openai": "npm:^0.1.31"
|
||||
"@dnd-kit/core": "npm:^6.3.1"
|
||||
"@dnd-kit/modifiers": "npm:^9.0.0"
|
||||
"@dnd-kit/sortable": "npm:^10.0.0"
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
"@electron-toolkit/eslint-config-prettier": "npm:^3.0.0"
|
||||
"@electron-toolkit/eslint-config-ts": "npm:^3.0.0"
|
||||
"@electron-toolkit/preload": "npm:^3.0.0"
|
||||
@ -21309,7 +21375,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1":
|
||||
"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1":
|
||||
version: 2.8.1
|
||||
resolution: "tslib@npm:2.8.1"
|
||||
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
|
||||
|
||||
Loading…
Reference in New Issue
Block a user