Feat/improve UI mcp settings (#4717)
* feat(MCPSettings): implement server selection and navigation with back button * chore(ui) * chore(UI): npx search padding * feat(NpxSearch): add server selection and navigation; update styles --------- Co-authored-by: eeee0717 <chentao020717Work@outlook.com>
This commit is contained in:
parent
10225512f4
commit
8eb6632620
@ -9,7 +9,7 @@ import { Button, Card, Flex, Input, Space, Spin, Tag, Typography } from 'antd'
|
|||||||
import { npxFinder } from 'npx-scope-finder'
|
import { npxFinder } from 'npx-scope-finder'
|
||||||
import { type FC, useEffect, useState } from 'react'
|
import { type FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled, { css } from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { SettingDivider, SettingGroup, SettingTitle } from '..'
|
import { SettingDivider, SettingGroup, SettingTitle } from '..'
|
||||||
|
|
||||||
@ -27,7 +27,10 @@ const npmScopes = ['@cherry', '@modelcontextprotocol', '@gongrzhe', '@mcpmarket'
|
|||||||
|
|
||||||
let _searchResults: SearchResult[] = []
|
let _searchResults: SearchResult[] = []
|
||||||
|
|
||||||
const NpxSearch: FC = () => {
|
const NpxSearch: FC<{
|
||||||
|
setSelectedMcpServer: (server: MCPServer) => void
|
||||||
|
setRoute: (route: string | null) => void
|
||||||
|
}> = ({ setSelectedMcpServer, setRoute }) => {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { Text, Link } = Typography
|
const { Text, Link } = Typography
|
||||||
@ -116,7 +119,7 @@ const NpxSearch: FC = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingGroup theme={theme} css={SettingGroupCss}>
|
<SettingGroup theme={theme} css={SettingGroup}>
|
||||||
<div>
|
<div>
|
||||||
<SettingTitle>
|
<SettingTitle>
|
||||||
{t('settings.mcp.npx_list.title')} <Text type="secondary">{t('settings.mcp.npx_list.desc')}</Text>
|
{t('settings.mcp.npx_list.title')} <Text type="secondary">{t('settings.mcp.npx_list.desc')}</Text>
|
||||||
@ -181,10 +184,11 @@ const NpxSearch: FC = () => {
|
|||||||
if (buildInServer) {
|
if (buildInServer) {
|
||||||
addMCPServer(buildInServer)
|
addMCPServer(buildInServer)
|
||||||
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' })
|
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' })
|
||||||
|
setSelectedMcpServer(buildInServer)
|
||||||
|
setRoute(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const newServer = {
|
||||||
addMCPServer({
|
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
name: record.name,
|
name: record.name,
|
||||||
description: `${record.description}\n\n${t('settings.mcp.npx_list.usage')}: ${record.usage}\n${t('settings.mcp.npx_list.npm')}: ${record.npmLink}`,
|
description: `${record.description}\n\n${t('settings.mcp.npx_list.usage')}: ${record.usage}\n${t('settings.mcp.npx_list.npm')}: ${record.npmLink}`,
|
||||||
@ -192,8 +196,12 @@ const NpxSearch: FC = () => {
|
|||||||
args: ['-y', record.fullName],
|
args: ['-y', record.fullName],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
type: record.type
|
type: record.type
|
||||||
})
|
}
|
||||||
|
|
||||||
|
addMCPServer(newServer)
|
||||||
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' })
|
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' })
|
||||||
|
setSelectedMcpServer(newServer)
|
||||||
|
setRoute(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
@ -215,14 +223,6 @@ const NpxSearch: FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingGroupCss = css`
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
const ResultList = styled.div`
|
const ResultList = styled.div`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -1,17 +1,13 @@
|
|||||||
import { CodeOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined, CodeOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import { nanoid } from '@reduxjs/toolkit'
|
import { nanoid } from '@reduxjs/toolkit'
|
||||||
import DragableList from '@renderer/components/DragableList'
|
|
||||||
import IndicatorLight from '@renderer/components/IndicatorLight'
|
import IndicatorLight from '@renderer/components/IndicatorLight'
|
||||||
import { HStack, VStack } from '@renderer/components/Layout'
|
import { HStack, VStack } from '@renderer/components/Layout'
|
||||||
import ListItem from '@renderer/components/ListItem'
|
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||||
import { EventEmitter } from '@renderer/services/EventService'
|
import { EventEmitter } from '@renderer/services/EventService'
|
||||||
import { MCPServer } from '@renderer/types'
|
import { MCPServer } from '@renderer/types'
|
||||||
import { Dropdown, MenuProps } from 'antd'
|
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
import { FC, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@ -22,8 +18,8 @@ import NpxSearch from './NpxSearch'
|
|||||||
|
|
||||||
const MCPSettings: FC = () => {
|
const MCPSettings: FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { mcpServers, addMCPServer, updateMcpServers, deleteMCPServer } = useMCPServers()
|
const { mcpServers, addMCPServer } = useMCPServers()
|
||||||
const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(mcpServers[0])
|
const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(null)
|
||||||
const [route, setRoute] = useState<'npx-search' | 'mcp-install' | null>(null)
|
const [route, setRoute] = useState<'npx-search' | 'mcp-install' | null>(null)
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
|
||||||
@ -49,50 +45,34 @@ const MCPSettings: FC = () => {
|
|||||||
addMCPServer(newServer)
|
addMCPServer(newServer)
|
||||||
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-list' })
|
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-list' })
|
||||||
setSelectedMcpServer(newServer)
|
setSelectedMcpServer(newServer)
|
||||||
|
setRoute(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDeleteMcpServer = useCallback(
|
|
||||||
async (server: MCPServer) => {
|
|
||||||
try {
|
|
||||||
await window.api.mcp.removeServer(server)
|
|
||||||
await deleteMCPServer(server.id)
|
|
||||||
window.message.success({ content: t('settings.mcp.deleteSuccess'), key: 'mcp-list' })
|
|
||||||
} catch (error: any) {
|
|
||||||
window.message.error({
|
|
||||||
content: `${t('settings.mcp.deleteError')}: ${error.message}`,
|
|
||||||
key: 'mcp-list'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[deleteMCPServer, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const getMenuItems = useCallback(
|
|
||||||
(server: MCPServer) => {
|
|
||||||
const menus: MenuProps['items'] = [
|
|
||||||
{
|
|
||||||
label: t('common.delete'),
|
|
||||||
danger: true,
|
|
||||||
key: 'delete',
|
|
||||||
icon: <DeleteOutlined />,
|
|
||||||
onClick: () => onDeleteMcpServer(server)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
return menus
|
|
||||||
},
|
|
||||||
[onDeleteMcpServer, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const _selectedMcpServer = mcpServers.find((server) => server.id === selectedMcpServer?.id)
|
const _selectedMcpServer = mcpServers.find((server) => server.id === selectedMcpServer?.id)
|
||||||
setSelectedMcpServer(_selectedMcpServer || mcpServers[0])
|
setSelectedMcpServer(_selectedMcpServer || mcpServers[0])
|
||||||
}, [mcpServers, route, selectedMcpServer])
|
}, [mcpServers, route, selectedMcpServer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if the selected server still exists in the updated mcpServers list
|
||||||
|
if (selectedMcpServer) {
|
||||||
|
const serverExists = mcpServers.some((server) => server.id === selectedMcpServer.id)
|
||||||
|
if (!serverExists) {
|
||||||
|
setSelectedMcpServer(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedMcpServer(null)
|
||||||
|
}
|
||||||
|
}, [mcpServers, selectedMcpServer])
|
||||||
|
|
||||||
const MainContent = useMemo(() => {
|
const MainContent = useMemo(() => {
|
||||||
if (route === 'npx-search' || isEmpty(mcpServers)) {
|
if (route === 'npx-search' || isEmpty(mcpServers)) {
|
||||||
return (
|
return (
|
||||||
<SettingContainer theme={theme}>
|
<SettingContainer theme={theme}>
|
||||||
<NpxSearch />
|
<NpxSearch
|
||||||
|
setRoute={(route) => setRoute(route as 'npx-search' | 'mcp-install' | null)}
|
||||||
|
setSelectedMcpServer={setSelectedMcpServer}
|
||||||
|
/>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -108,53 +88,70 @@ const MCPSettings: FC = () => {
|
|||||||
return <McpSettings server={selectedMcpServer} />
|
return <McpSettings server={selectedMcpServer} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <NpxSearch />
|
return (
|
||||||
|
<NpxSearch
|
||||||
|
setRoute={(route) => setRoute(route as 'npx-search' | 'mcp-install' | null)}
|
||||||
|
setSelectedMcpServer={setSelectedMcpServer}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}, [mcpServers, route, selectedMcpServer, theme])
|
}, [mcpServers, route, selectedMcpServer, theme])
|
||||||
|
|
||||||
|
const goBackToGrid = () => {
|
||||||
|
setSelectedMcpServer(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<McpListContainer>
|
{selectedMcpServer ? (
|
||||||
<McpList>
|
<DetailViewContainer>
|
||||||
<ListItem
|
<BackButtonContainer>
|
||||||
key="add"
|
<BackButton onClick={goBackToGrid}>
|
||||||
title={t('settings.mcp.addServer')}
|
<ArrowLeftOutlined /> {t('common.back')}
|
||||||
active={false}
|
</BackButton>
|
||||||
onClick={onAddMcpServer}
|
</BackButtonContainer>
|
||||||
icon={<PlusOutlined />}
|
<DetailContent>{MainContent}</DetailContent>
|
||||||
titleStyle={{ fontWeight: 500 }}
|
</DetailViewContainer>
|
||||||
style={{ width: '100%', marginTop: -2 }}
|
) : (
|
||||||
/>
|
<GridContainer>
|
||||||
<DragableList list={mcpServers} onUpdate={updateMcpServers}>
|
<GridHeader>
|
||||||
{(server: MCPServer) => (
|
<h2>{t('settings.mcp.newServer')}</h2>
|
||||||
<Dropdown menu={{ items: getMenuItems(server) }} trigger={['contextMenu']} key={server.id}>
|
</GridHeader>
|
||||||
<div>
|
<ServersGrid>
|
||||||
<ListItem
|
<AddServerCard onClick={onAddMcpServer}>
|
||||||
|
<PlusOutlined style={{ fontSize: 24 }} />
|
||||||
|
<AddServerText>{t('settings.mcp.addServer')}</AddServerText>
|
||||||
|
</AddServerCard>
|
||||||
|
|
||||||
|
{mcpServers.map((server) => (
|
||||||
|
<ServerCard
|
||||||
key={server.id}
|
key={server.id}
|
||||||
title={server.name}
|
|
||||||
active={selectedMcpServer?.id === server.id}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMcpServer(server)
|
setSelectedMcpServer(server)
|
||||||
setRoute(null)
|
setRoute(null)
|
||||||
}}
|
}}>
|
||||||
titleStyle={{ fontWeight: 500 }}
|
<ServerHeader>
|
||||||
icon={<CodeOutlined />}
|
<ServerIcon>
|
||||||
rightContent={
|
<CodeOutlined />
|
||||||
|
</ServerIcon>
|
||||||
|
<ServerName>{server.name}</ServerName>
|
||||||
|
<StatusIndicator>
|
||||||
<IndicatorLight
|
<IndicatorLight
|
||||||
size={6}
|
size={6}
|
||||||
color={server.isActive ? 'green' : 'var(--color-text-3)'}
|
color={server.isActive ? 'green' : 'var(--color-text-3)'}
|
||||||
animation={server.isActive}
|
animation={server.isActive}
|
||||||
shadow={false}
|
shadow={false}
|
||||||
style={{ marginRight: 4 }}
|
|
||||||
/>
|
/>
|
||||||
}
|
</StatusIndicator>
|
||||||
/>
|
</ServerHeader>
|
||||||
</div>
|
<ServerDescription>
|
||||||
</Dropdown>
|
{server.description &&
|
||||||
|
server.description.substring(0, 60) + (server.description.length > 60 ? '...' : '')}
|
||||||
|
</ServerDescription>
|
||||||
|
</ServerCard>
|
||||||
|
))}
|
||||||
|
</ServersGrid>
|
||||||
|
</GridContainer>
|
||||||
)}
|
)}
|
||||||
</DragableList>
|
|
||||||
</McpList>
|
|
||||||
</McpListContainer>
|
|
||||||
{MainContent}
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -163,22 +160,129 @@ const Container = styled(HStack)`
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
const McpListContainer = styled(VStack)`
|
const GridContainer = styled(VStack)`
|
||||||
width: var(--settings-width);
|
width: 100%;
|
||||||
border-right: 0.5px solid var(--color-border);
|
|
||||||
height: calc(100vh - var(--navbar-height));
|
height: calc(100vh - var(--navbar-height));
|
||||||
|
padding: 20px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const McpList = styled(Scrollbar)`
|
const GridHeader = styled.div`
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding-bottom: 16px;
|
||||||
.iconfont {
|
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ServersGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ServerCard = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
height: 140px;
|
||||||
|
background-color: var(--color-bg-1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ServerHeader = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ServerIcon = styled.div`
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-right: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ServerName = styled.div`
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StatusIndicator = styled.div`
|
||||||
|
margin-left: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ServerDescription = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
line-height: 16px;
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
`
|
||||||
|
|
||||||
|
const AddServerCard = styled(ServerCard)`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-style: dashed;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
`
|
||||||
|
|
||||||
|
const AddServerText = styled.div`
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
`
|
||||||
|
|
||||||
|
const DetailViewContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
`
|
||||||
|
|
||||||
|
const BackButtonContainer = styled.div`
|
||||||
|
padding: 16px 0 0 20px;
|
||||||
|
width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const DetailContent = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const BackButton = styled.div`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background-color: var(--color-bg-1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background-color: var(--color-bg-2);
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export const SettingContainer = styled.div<{ theme?: ThemeMode }>`
|
|||||||
height: calc(100vh - var(--navbar-height));
|
height: calc(100vh - var(--navbar-height));
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
|
padding-bottom: 75px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
font-family: Ubuntu;
|
font-family: Ubuntu;
|
||||||
background: ${(props) => (props.theme === 'dark' ? 'transparent' : 'var(--color-background-soft)')};
|
background: ${(props) => (props.theme === 'dark' ? 'transparent' : 'var(--color-background-soft)')};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user