feat: Add context menu to DevChat

Added a context menu to DevChat that allows users to select code or file and right-click to add it as a context. The context is then added to the list of contexts in the chat panel.
This commit is contained in:
Rankin Zheng 2023-05-10 17:38:24 +08:00
parent dd3e6a9fea
commit 26fb027c6c

View File

@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Avatar, Center, Container, CopyButton, Divider, Flex, Grid, Stack, Textarea, TypographyStylesProvider, px, rem, useMantineTheme } from '@mantine/core'; import { Accordion, AccordionControlProps, Avatar, Box, Center, Container, CopyButton, Divider, Flex, Grid, Stack, Textarea, TypographyStylesProvider, px, rem, useMantineTheme } from '@mantine/core';
import { Input, Tooltip } from '@mantine/core'; import { Input, Tooltip } from '@mantine/core';
import { List } from '@mantine/core'; import { List } from '@mantine/core';
import { ScrollArea } from '@mantine/core'; import { ScrollArea } from '@mantine/core';
@ -8,7 +8,7 @@ import { createStyles, keyframes } from '@mantine/core';
import { ActionIcon } from '@mantine/core'; import { ActionIcon } from '@mantine/core';
import { Menu, Button, Text } from '@mantine/core'; import { Menu, Button, Text } from '@mantine/core';
import { useElementSize, useListState, useResizeObserver, useViewportSize } from '@mantine/hooks'; import { useElementSize, useListState, useResizeObserver, useViewportSize } from '@mantine/hooks';
import { IconAdjustments, IconBulb, IconCheck, IconClick, IconColumnInsertRight, IconCopy, IconEdit, IconFileDiff, IconFolder, IconGitCompare, IconMessageDots, IconMessagePlus, IconPrompt, IconRobot, IconSend, IconSquareRoundedPlus, IconTerminal2, IconUser } from '@tabler/icons-react'; import { IconAdjustments, IconBulb, IconCameraSelfie, IconCheck, IconClick, IconColumnInsertRight, IconCopy, IconDots, IconEdit, IconFileDiff, IconFolder, IconGitCompare, IconMessageDots, IconMessagePlus, IconPrinter, IconPrompt, IconReplace, IconRobot, IconSend, IconSquareRoundedPlus, IconTerminal2, IconUser, IconX } from '@tabler/icons-react';
import { IconSettings, IconSearch, IconPhoto, IconMessageCircle, IconTrash, IconArrowsLeftRight } from '@tabler/icons-react'; import { IconSettings, IconSearch, IconPhoto, IconMessageCircle, IconTrash, IconArrowsLeftRight } from '@tabler/icons-react';
import { Prism } from '@mantine/prism'; import { Prism } from '@mantine/prism';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
@ -37,8 +37,9 @@ const chatPanel = () => {
const chatContainerRef = useRef<HTMLDivElement>(null); const chatContainerRef = useRef<HTMLDivElement>(null);
const scrollViewport = useRef<HTMLDivElement>(null); const scrollViewport = useRef<HTMLDivElement>(null);
const [messages, messageHandlers] = useListState<{ type: string; message: string; }>([]); const [messages, messageHandlers] = useListState<{ type: string; message: string; }>([]);
const [commands, commandHandlers] = useListState<{ pattern: string; description: string; name: string }>([]); const [commandMenus, commandMenusHandlers] = useListState<{ pattern: string; description: string; name: string }>([]);
const [contexts, contextHandlers] = useListState<{ pattern: string; description: string; name: string }>([]); const [contextMenus, contextMenusHandlers] = useListState<{ pattern: string; description: string; name: string }>([]);
const [contexts, contextsHandlers] = useListState<any>([]);
const [currentMessage, setCurrentMessage] = useState(''); const [currentMessage, setCurrentMessage] = useState('');
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [responsed, setResponsed] = useState(false); const [responsed, setResponsed] = useState(false);
@ -76,6 +77,15 @@ const chatPanel = () => {
setCurrentMessage(''); setCurrentMessage('');
} }
}; };
const handleContextClick = (contextName: string) => {
// Process and send the message to the extension
messageUtil.sendMessage({
command: 'addContext',
selected: contextName
});
};
const scrollToBottom = () => const scrollToBottom = () =>
scrollViewport?.current?.scrollTo({ top: scrollViewport.current.scrollHeight, behavior: 'smooth' }); scrollViewport?.current?.scrollTo({ top: scrollViewport.current.scrollHeight, behavior: 'smooth' });
@ -118,12 +128,38 @@ const chatPanel = () => {
scrollToBottom(); scrollToBottom();
}); });
messageUtil.registerHandler('regCommandList', (message: { result: { pattern: string; description: string; name: string }[] }) => { messageUtil.registerHandler('regCommandList', (message: { result: { pattern: string; description: string; name: string }[] }) => {
commandHandlers.append(...message.result); commandMenusHandlers.append(...message.result);
console.log(`commands:${commands}`);
}); });
messageUtil.registerHandler('regContextList', (message: { result: { pattern: string; description: string; name: string }[] }) => { messageUtil.registerHandler('regContextList', (message: { result: { pattern: string; description: string; name: string }[] }) => {
contextHandlers.append(...message.result); contextMenusHandlers.append(...message.result);
console.log(`contexts:${commands}`); });
messageUtil.registerHandler('appendContext', (message: { command: string; context: string }) => {
// context is a temp file path
const match = /\|([^]+?)\]/.exec(message.context);
// Process and send the message to the extension
messageUtil.sendMessage({
command: 'contextDetail',
file: match && match[1],
});
});
messageUtil.registerHandler('contextDetailResponse', (message: { command: string; file: string; result: string }) => {
//result is a content json
// 1. diff json structure
// {
// languageId: languageId,
// path: fileSelected,
// content: codeSelected
// };
// 2. command json structure
// {
// command: commandString,
// content: stdout
// };
const context = JSON.parse(message.result);
if (typeof context !== 'undefined' && context) {
contextsHandlers.append(context);
console.log(context);
}
}); });
}, [registed]); }, [registed]);
@ -149,7 +185,7 @@ const chatPanel = () => {
<Text size="lg" color="gray" weight={500}>No messages yet</Text> <Text size="lg" color="gray" weight={500}>No messages yet</Text>
</Center>); </Center>);
const commandMenus = commands.map(({ pattern, description, name }, index) => { const commandMenusNode = commandMenus.map(({ pattern, description, name }, index) => {
return ( return (
<Menu.Item <Menu.Item
onClick={() => { setInput(`/${pattern} `); }} onClick={() => { setInput(`/${pattern} `); }}
@ -170,17 +206,19 @@ const chatPanel = () => {
</Menu.Item>); </Menu.Item>);
}); });
const contextMenus = contexts.map(({ pattern, description, name }, index) => { const contextMenusNode = contextMenus.map(({ pattern, description, name }, index) => {
return ( return (
<Menu.Item <Menu.Item
onClick={() => { setInput(`/${name} `); }} onClick={() => {
handleContextClick(name);
}}
icon={<IconMessagePlus size={16} />} icon={<IconMessagePlus size={16} />}
> >
<Text sx={{ <Text sx={{
fontSize: 'sm', fontSize: 'sm',
fontWeight: 'bolder', fontWeight: 'bolder',
}}> }}>
/{name} {name}
</Text> </Text>
<Text sx={{ <Text sx={{
fontSize: 'sm', fontSize: 'sm',
@ -191,7 +229,6 @@ const chatPanel = () => {
</Menu.Item>); </Menu.Item>);
}); });
const messageList = messages.map(({ message: messageText, type: messageType }, index) => { const messageList = messages.map(({ message: messageText, type: messageType }, index) => {
// setMessage(messageText); // setMessage(messageText);
return (<> return (<>
@ -268,11 +305,16 @@ const chatPanel = () => {
<IconFileDiff size="1.125rem" /> <IconFileDiff size="1.125rem" />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label='Insert' withArrow position="left" color="gray"> <Tooltip label='Insert Code' withArrow position="left" color="gray">
<ActionIcon> <ActionIcon>
<IconColumnInsertRight size="1.125rem" /> <IconColumnInsertRight size="1.125rem" />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label='Replace' withArrow position="left" color="gray">
<ActionIcon>
<IconReplace size="1.125rem" />
</ActionIcon>
</Tooltip>
</Flex> </Flex>
<SyntaxHighlighter {...props} language={match[1]} customStyle={{ padding: '2em 1em 1em 2em' }} style={okaidia} PreTag="div"> <SyntaxHighlighter {...props} language={match[1]} customStyle={{ padding: '2em 1em 1em 2em' }} style={okaidia} PreTag="div">
{value} {value}
@ -317,69 +359,103 @@ const chatPanel = () => {
viewportRef={scrollViewport}> viewportRef={scrollViewport}>
{messageList.length > 0 ? messageList : defaultMessages} {messageList.length > 0 ? messageList : defaultMessages}
</ScrollArea> </ScrollArea>
<Menu <Stack sx={{ position: 'absolute', bottom: 10, width: scrollViewport.current?.clientWidth }}>
id='commandMenu' <Accordion variant="contained" chevronPosition="left" style={{ backgroundColor: '#FFF' }}>
position='top-start' {
closeOnClickOutside={true} contexts.map((context, index) => {
shadow="xs" return (
width={scrollViewport.current?.clientWidth} <Accordion.Item value={`item-${index}`} mah='200'>
opened={menuOpend} <Box sx={{ display: 'flex', alignItems: 'center' }}>
onChange={setMenuOpend} <Accordion.Control >
onClose={() => setMenuType('')} {'command' in context ? context.command : context.path}
onOpen={() => menuType !== '' ? setMenuOpend(true) : setMenuOpend(false)} </Accordion.Control>
returnFocus={true}> <ActionIcon
<Menu.Target> mr={8}
<Textarea size="lg"
id='chat-textarea' onClick={() => {
disabled={generating} contextsHandlers.remove(index);
value={input} }}>
ref={inputRef} <IconX size="1rem" />
onKeyDown={handleKeyDown} </ActionIcon>
onChange={handleInputChange} </Box>
autosize <Accordion.Panel>
minRows={1} {
maxRows={10} context.content
radius="md" ? context.content
size="md" : <Center>
sx={{ pointerEvents: 'all', position: 'absolute', bottom: 10, width: scrollViewport.current?.clientWidth }} <Text c='gray.3'>No content</Text>
placeholder="Ctrl + Enter Send a message." </Center>
styles={{ icon: { alignItems: 'flex-start', paddingTop: '9px' }, rightSection: { alignItems: 'flex-start', paddingTop: '9px' } }} }
icon={
<ActionIcon onClick={handlePlusClick} sx={{ pointerEvents: 'all' }}>
<IconSquareRoundedPlus size="1rem" />
</ActionIcon>
}
rightSection={
<ActionIcon onClick={handleSendClick}>
<IconSend size="1rem" />
</ActionIcon>
}
/>
</Menu.Target>
{ </Accordion.Panel>
menuType === 'contexts' </Accordion.Item>
? (<Menu.Dropdown> );
<Text })
c="dimmed" }
ta="left" </Accordion>
fz='sm' <Menu
m='12px'> id='commandMenu'
<IconBulb size={14} style={{ marginTop: '2px', marginRight: '2px' }} /> position='top-start'
Tips: Select code or file & right click closeOnClickOutside={true}
</Text> shadow="xs"
<Divider /> width={scrollViewport.current?.clientWidth}
<Menu.Label>DevChat Contexts</Menu.Label> opened={menuOpend}
{contextMenus} onChange={setMenuOpend}
</Menu.Dropdown>) onClose={() => setMenuType('')}
: menuType === 'commands' onOpen={() => menuType !== '' ? setMenuOpend(true) : setMenuOpend(false)}
? <Menu.Dropdown> returnFocus={true}>
<Menu.Label>DevChat Commands</Menu.Label> <Menu.Target>
{commandMenus} <Textarea
</Menu.Dropdown> id='chat-textarea'
: <></> disabled={generating}
} value={input}
</Menu> ref={inputRef}
onKeyDown={handleKeyDown}
onChange={handleInputChange}
autosize
minRows={1}
maxRows={10}
radius="md"
size="md"
sx={{ pointerEvents: 'all' }}
placeholder="Ctrl + Enter Send a message."
styles={{ icon: { alignItems: 'flex-start', paddingTop: '9px' }, rightSection: { alignItems: 'flex-start', paddingTop: '9px' } }}
icon={
<ActionIcon onClick={handlePlusClick} sx={{ pointerEvents: 'all' }}>
<IconSquareRoundedPlus size="1rem" />
</ActionIcon>
}
rightSection={
<ActionIcon onClick={handleSendClick}>
<IconSend size="1rem" />
</ActionIcon>
}
/>
</Menu.Target>
{
menuType === 'contexts'
? (<Menu.Dropdown>
<Text
c="dimmed"
ta="left"
fz='sm'
m='12px'>
<IconBulb size={14} style={{ marginTop: '2px', marginRight: '2px' }} />
Tips: Select code or file & right click
</Text>
<Divider />
<Menu.Label>DevChat Contexts</Menu.Label>
{contextMenusNode}
</Menu.Dropdown>)
: menuType === 'commands'
? <Menu.Dropdown>
<Menu.Label>DevChat Commands</Menu.Label>
{commandMenusNode}
</Menu.Dropdown>
: <></>
}
</Menu>
</Stack>
</Container > </Container >
); );
}; };