mirror of
https://github.com/EthanMarti/infio-copilot.git
synced 2026-01-18 17:22:52 +00:00
update chat view, handl tool & new md component
This commit is contained in:
parent
c0c81bd1d8
commit
23e7a5d5d7
@ -1,3 +1,5 @@
|
|||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { CircleStop, History, Plus } from 'lucide-react'
|
import { CircleStop, History, Plus } from 'lucide-react'
|
||||||
import { App, Notice } from 'obsidian'
|
import { App, Notice } from 'obsidian'
|
||||||
@ -24,23 +26,31 @@ import {
|
|||||||
LLMBaseUrlNotSetException,
|
LLMBaseUrlNotSetException,
|
||||||
LLMModelNotSetException,
|
LLMModelNotSetException,
|
||||||
} from '../../core/llm/exception'
|
} from '../../core/llm/exception'
|
||||||
|
import { regexSearchFiles } from '../../core/services/ripgrep'
|
||||||
import { useChatHistory } from '../../hooks/use-chat-history'
|
import { useChatHistory } from '../../hooks/use-chat-history'
|
||||||
|
import { ApplyStatus, ToolArgs } from '../../types/apply'
|
||||||
import { ChatMessage, ChatUserMessage } from '../../types/chat'
|
import { ChatMessage, ChatUserMessage } from '../../types/chat'
|
||||||
import {
|
import {
|
||||||
MentionableBlock,
|
MentionableBlock,
|
||||||
MentionableBlockData,
|
MentionableBlockData,
|
||||||
MentionableCurrentFile,
|
MentionableCurrentFile,
|
||||||
} from '../../types/mentionable'
|
} from '../../types/mentionable'
|
||||||
import { manualApplyChangesToFile } from '../../utils/apply'
|
import { ApplyEditToFile, SearchAndReplace } from '../../utils/apply'
|
||||||
|
import { listFilesAndFolders } from '../../utils/glob-utils'
|
||||||
import {
|
import {
|
||||||
getMentionableKey,
|
getMentionableKey,
|
||||||
serializeMentionable,
|
serializeMentionable,
|
||||||
} from '../../utils/mentionable'
|
} from '../../utils/mentionable'
|
||||||
import { readTFileContent } from '../../utils/obsidian'
|
import { readTFileContent } from '../../utils/obsidian'
|
||||||
import { openSettingsModalWithError } from '../../utils/open-settings-modal'
|
import { openSettingsModalWithError } from '../../utils/open-settings-modal'
|
||||||
import { PromptGenerator } from '../../utils/prompt-generator'
|
import { PromptGenerator, addLineNumbers } from '../../utils/prompt-generator'
|
||||||
|
|
||||||
|
// Simple file reading function that returns a placeholder content for testing
|
||||||
|
const readFileContent = (filePath: string): string => {
|
||||||
|
// In a real implementation, this would use filePath to read the actual file
|
||||||
|
return `Content of file: ${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
import AssistantMessageActions from './AssistantMessageActions'
|
|
||||||
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
|
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
|
||||||
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
||||||
import { ChatHistory } from './ChatHistory'
|
import { ChatHistory } from './ChatHistory'
|
||||||
@ -54,6 +64,7 @@ import SimilaritySearchResults from './SimilaritySearchResults'
|
|||||||
const getNewInputMessage = (app: App): ChatUserMessage => {
|
const getNewInputMessage = (app: App): ChatUserMessage => {
|
||||||
return {
|
return {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
content: null,
|
content: null,
|
||||||
promptContent: null,
|
promptContent: null,
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@ -239,19 +250,6 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const responseMessageId = uuidv4()
|
const responseMessageId = uuidv4()
|
||||||
setChatMessages([
|
|
||||||
...newChatHistory,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
reasoningContent: '',
|
|
||||||
id: responseMessageId,
|
|
||||||
metadata: {
|
|
||||||
usage: undefined,
|
|
||||||
model: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
@ -271,6 +269,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
...compiledMessages,
|
...compiledMessages,
|
||||||
{
|
{
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
content: '',
|
content: '',
|
||||||
reasoningContent: '',
|
reasoningContent: '',
|
||||||
id: responseMessageId,
|
id: responseMessageId,
|
||||||
@ -284,6 +283,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
chatModel,
|
chatModel,
|
||||||
{
|
{
|
||||||
model: chatModel.modelId,
|
model: chatModel.modelId,
|
||||||
|
temperature: 0.5,
|
||||||
messages: requestMessages,
|
messages: requestMessages,
|
||||||
stream: true,
|
stream: true,
|
||||||
},
|
},
|
||||||
@ -348,45 +348,214 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
submitMutation.mutate({ newChatHistory, useVaultSearch })
|
submitMutation.mutate({ newChatHistory, useVaultSearch })
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyMutation = useMutation({
|
const applyMutation = useMutation<
|
||||||
mutationFn: async ({
|
{
|
||||||
blockInfo,
|
type: string;
|
||||||
}: {
|
applyMsgId: string;
|
||||||
blockInfo: {
|
applyStatus: ApplyStatus;
|
||||||
content: string
|
returnMsg?: ChatUserMessage
|
||||||
filename?: string
|
},
|
||||||
startLine?: number
|
Error,
|
||||||
endLine?: number
|
{ applyMsgId: string, toolArgs: ToolArgs }
|
||||||
}
|
>({
|
||||||
}) => {
|
mutationFn: async ({ applyMsgId, toolArgs }) => {
|
||||||
|
try {
|
||||||
const activeFile = app.workspace.getActiveFile()
|
const activeFile = app.workspace.getActiveFile()
|
||||||
if (!activeFile) {
|
if (!activeFile) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'No file is currently open to apply changes. Please open a file and try again.',
|
'No file is currently open to apply changes. Please open a file and try again.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeFileContent = await readTFileContent(activeFile, app.vault)
|
const activeFileContent = await readTFileContent(activeFile, app.vault)
|
||||||
|
|
||||||
const updatedFileContent = await manualApplyChangesToFile(
|
if (toolArgs.type === 'write_to_file' || toolArgs.type === 'insert_content') {
|
||||||
blockInfo.content,
|
const applyRes = await ApplyEditToFile(
|
||||||
activeFile,
|
activeFile,
|
||||||
activeFileContent,
|
activeFileContent,
|
||||||
blockInfo.startLine,
|
toolArgs.content,
|
||||||
blockInfo.endLine
|
toolArgs.startLine,
|
||||||
|
toolArgs.endLine
|
||||||
)
|
)
|
||||||
if (!updatedFileContent) {
|
if (!applyRes) {
|
||||||
throw new Error('Failed to apply changes')
|
throw new Error('Failed to apply edit changes')
|
||||||
}
|
}
|
||||||
|
// 返回一个Promise,该Promise会在用户做出选择后解析
|
||||||
await app.workspace.getLeaf(true).setViewState({
|
return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => {
|
||||||
|
app.workspace.getLeaf(true).setViewState({
|
||||||
type: APPLY_VIEW_TYPE,
|
type: APPLY_VIEW_TYPE,
|
||||||
active: true,
|
active: true,
|
||||||
state: {
|
state: {
|
||||||
file: activeFile,
|
file: activeFile,
|
||||||
originalContent: activeFileContent,
|
originalContent: activeFileContent,
|
||||||
newContent: updatedFileContent,
|
newContent: applyRes,
|
||||||
|
onClose: (applied: boolean) => {
|
||||||
|
const applyStatus = applied ? ApplyStatus.Applied : ApplyStatus.Rejected
|
||||||
|
const applyEditContent = applied ? 'Changes successfully applied'
|
||||||
|
: 'User rejected changes'
|
||||||
|
resolve({
|
||||||
|
type: 'write_to_file',
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: `[write_to_file for '${toolArgs.filepath}'] Result:\n${applyEditContent}\n`,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} satisfies ApplyViewState,
|
} satisfies ApplyViewState,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
} else if (toolArgs.type === 'search_and_replace') {
|
||||||
|
const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath)
|
||||||
|
const applyRes = await SearchAndReplace(
|
||||||
|
activeFile,
|
||||||
|
fileContent,
|
||||||
|
toolArgs.operations
|
||||||
|
)
|
||||||
|
// 返回一个Promise,该Promise会在用户做出选择后解析
|
||||||
|
return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => {
|
||||||
|
app.workspace.getLeaf(true).setViewState({
|
||||||
|
type: APPLY_VIEW_TYPE,
|
||||||
|
active: true,
|
||||||
|
state: {
|
||||||
|
file: activeFile,
|
||||||
|
originalContent: activeFileContent,
|
||||||
|
newContent: applyRes,
|
||||||
|
onClose: (applied: boolean) => {
|
||||||
|
const applyStatus = applied ? ApplyStatus.Applied : ApplyStatus.Rejected
|
||||||
|
const applyEditContent = applied ? 'Changes successfully applied'
|
||||||
|
: 'User rejected changes'
|
||||||
|
resolve({
|
||||||
|
type: 'search_and_replace',
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: `[search_and_replace for '${toolArgs.filepath}'] Result:\n${applyEditContent}\n`,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} satisfies ApplyViewState,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else if (toolArgs.type === 'read_file') {
|
||||||
|
const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath)
|
||||||
|
const formattedContent = `[read_file for '${toolArgs.filepath}'] Result:\n${addLineNumbers(fileContent)}\n`;
|
||||||
|
return {
|
||||||
|
type: 'read_file',
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus: ApplyStatus.Applied,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: formattedContent,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else if (toolArgs.type === 'list_files') {
|
||||||
|
const files = await listFilesAndFolders(app.vault, toolArgs.filepath)
|
||||||
|
const formattedContent = `[list_files for '${toolArgs.filepath}'] Result:\n${files.join('\n')}\n`;
|
||||||
|
return {
|
||||||
|
type: 'list_files',
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus: ApplyStatus.Applied,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: formattedContent,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (toolArgs.type === 'regex_search_files') {
|
||||||
|
const baseVaultPath = app.vault.adapter.getBasePath()
|
||||||
|
const absolutePath = path.join(baseVaultPath, toolArgs.filepath)
|
||||||
|
console.log("absolutePath", absolutePath)
|
||||||
|
const results = await regexSearchFiles(absolutePath, toolArgs.regex)
|
||||||
|
console.log("results", results)
|
||||||
|
const formattedContent = `[regex_search_files for '${toolArgs.filepath}'] Result:\n${results}\n`;
|
||||||
|
return {
|
||||||
|
type: 'regex_search_files',
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus: ApplyStatus.Applied,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: formattedContent,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (toolArgs.type === 'semantic_search_files') {
|
||||||
|
const scope_folders = toolArgs.filepath
|
||||||
|
&& toolArgs.filepath !== ''
|
||||||
|
&& toolArgs.filepath !== '.'
|
||||||
|
&& toolArgs.filepath !== '/'
|
||||||
|
? { files: [], folders: [toolArgs.filepath] }
|
||||||
|
: undefined
|
||||||
|
const results = await (await getRAGEngine()).processQuery({
|
||||||
|
query: toolArgs.query,
|
||||||
|
scope: scope_folders,
|
||||||
|
})
|
||||||
|
console.log("results", results)
|
||||||
|
let snippets = results.map(({ path, content, metadata }) => {
|
||||||
|
const contentWithLineNumbers = addLineNumbers(content, metadata.startLine)
|
||||||
|
return `<file_block_content location="${path}#L${metadata.startLine}-${metadata.endLine}">\n${contentWithLineNumbers}\n</file_block_content>`
|
||||||
|
}).join('\n\n')
|
||||||
|
if (snippets.length === 0) {
|
||||||
|
snippets = `No results found for '${toolArgs.query}'`
|
||||||
|
}
|
||||||
|
const formattedContent = `[semantic_search_files for '${toolArgs.filepath}'] Result:\n${snippets}\n`;
|
||||||
|
return {
|
||||||
|
type: 'semantic_search_files',
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus: ApplyStatus.Applied,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: formattedContent,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to apply changes', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.applyMsgId || result.returnMsg) {
|
||||||
|
let newChatMessages = [...chatMessages];
|
||||||
|
|
||||||
|
if (result.applyMsgId) {
|
||||||
|
newChatMessages = newChatMessages.map((message) =>
|
||||||
|
message.role === 'assistant' && message.id === result.applyMsgId ? {
|
||||||
|
...message,
|
||||||
|
applyStatus: result.applyStatus
|
||||||
|
} : message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setChatMessages(newChatMessages);
|
||||||
|
|
||||||
|
if (result.returnMsg) {
|
||||||
|
handleSubmit([...newChatMessages, result.returnMsg], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (
|
if (
|
||||||
@ -404,13 +573,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleApply = useCallback(
|
const handleApply = useCallback(
|
||||||
(blockInfo: {
|
(applyMsgId: string, toolArgs: ToolArgs) => {
|
||||||
content: string
|
applyMutation.mutate({ applyMsgId, toolArgs })
|
||||||
filename?: string
|
|
||||||
startLine?: number
|
|
||||||
endLine?: number
|
|
||||||
}) => {
|
|
||||||
applyMutation.mutate({ blockInfo })
|
|
||||||
},
|
},
|
||||||
[applyMutation],
|
[applyMutation],
|
||||||
)
|
)
|
||||||
@ -420,7 +584,6 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
//
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateConversationAsync = async () => {
|
const updateConversationAsync = async () => {
|
||||||
try {
|
try {
|
||||||
@ -597,8 +760,10 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
{chatMessages.map((message, index) =>
|
{chatMessages.map((message, index) =>
|
||||||
message.role === 'user' ? (
|
message.role === 'user' ? (
|
||||||
<div key={message.id} className="infio-chat-messages-user">
|
message.content &&
|
||||||
|
<div key={"user-" + message.id} className="infio-chat-messages-user">
|
||||||
<PromptInputWithActions
|
<PromptInputWithActions
|
||||||
|
key={"input-" + message.id}
|
||||||
ref={(ref) => registerChatUserInputRef(message.id, ref)}
|
ref={(ref) => registerChatUserInputRef(message.id, ref)}
|
||||||
initialSerializedEditorState={message.content}
|
initialSerializedEditorState={message.content}
|
||||||
onSubmit={(content, useVaultSearch) => {
|
onSubmit={(content, useVaultSearch) => {
|
||||||
@ -608,6 +773,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
...chatMessages.slice(0, index),
|
...chatMessages.slice(0, index),
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
content: content,
|
content: content,
|
||||||
promptContent: null,
|
promptContent: null,
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@ -632,20 +798,24 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
/>
|
/>
|
||||||
{message.similaritySearchResults && (
|
{message.similaritySearchResults && (
|
||||||
<SimilaritySearchResults
|
<SimilaritySearchResults
|
||||||
|
key={"similarity-search-" + message.id}
|
||||||
similaritySearchResults={message.similaritySearchResults}
|
similaritySearchResults={message.similaritySearchResults}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div key={message.id} className="infio-chat-messages-assistant">
|
<div key={"assistant-" + message.id} className="infio-chat-messages-assistant">
|
||||||
<MarkdownReasoningBlock reasoningContent={message.reasoningContent} />
|
<MarkdownReasoningBlock
|
||||||
|
key={"reasoning-" + message.id}
|
||||||
|
reasoningContent={message.reasoningContent} />
|
||||||
<ReactMarkdownItem
|
<ReactMarkdownItem
|
||||||
handleApply={handleApply}
|
key={"content-" + message.id}
|
||||||
isApplying={applyMutation.isPending}
|
handleApply={(toolArgs) => handleApply(message.id, toolArgs)}
|
||||||
|
applyStatus={message.applyStatus}
|
||||||
>
|
>
|
||||||
{message.content}
|
{message.content}
|
||||||
</ReactMarkdownItem>
|
</ReactMarkdownItem>
|
||||||
{message.content && <AssistantMessageActions message={message} />}
|
{/* {message.content && <AssistantMessageActions key={"actions-" + message.id} message={message} />} */}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@ -690,20 +860,19 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
|
|
||||||
function ReactMarkdownItem({
|
function ReactMarkdownItem({
|
||||||
handleApply,
|
handleApply,
|
||||||
isApplying,
|
applyStatus,
|
||||||
|
// applyMutation,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
handleApply: (blockInfo: {
|
handleApply: (toolArgs: ToolArgs) => void
|
||||||
content: string
|
applyStatus: ApplyStatus
|
||||||
filename?: string
|
|
||||||
startLine?: number
|
|
||||||
endLine?: number
|
|
||||||
}) => void
|
|
||||||
isApplying: boolean
|
|
||||||
children: string
|
children: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ReactMarkdown onApply={handleApply} isApplying={isApplying}>
|
<ReactMarkdown
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
onApply={handleApply}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,67 +1,140 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
|
|
||||||
|
import { ApplyStatus, ToolArgs } from '../../types/apply'
|
||||||
import {
|
import {
|
||||||
InfioBlockAction,
|
ParsedMsgBlock,
|
||||||
ParsedInfioBlock,
|
parseMsgBlocks,
|
||||||
parseinfioBlocks,
|
|
||||||
} from '../../utils/parse-infio-block'
|
} from '../../utils/parse-infio-block'
|
||||||
|
|
||||||
import MarkdownActionBlock from './MarkdownActionBlock'
|
import MarkdownEditFileBlock from './MarkdownEditFileBlock'
|
||||||
import MarkdownReferenceBlock from './MarkdownReferenceBlock'
|
import MarkdownListFilesBlock from './MarkdownListFilesBlock'
|
||||||
|
import MarkdownReadFileBlock from './MarkdownReadFileBlock'
|
||||||
import MarkdownReasoningBlock from './MarkdownReasoningBlock'
|
import MarkdownReasoningBlock from './MarkdownReasoningBlock'
|
||||||
|
import MarkdownRegexSearchFilesBlock from './MarkdownRegexSearchFilesBlock'
|
||||||
|
import MarkdownSearchAndReplace from './MarkdownSearchAndReplace'
|
||||||
|
import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock'
|
||||||
|
import MarkdownWithIcons from './MarkdownWithIcon'
|
||||||
|
|
||||||
function ReactMarkdown({
|
function ReactMarkdown({
|
||||||
|
applyStatus,
|
||||||
onApply,
|
onApply,
|
||||||
isApplying,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onApply: (blockInfo: {
|
applyStatus: ApplyStatus
|
||||||
content: string
|
onApply: (toolArgs: ToolArgs) => void
|
||||||
filename?: string
|
|
||||||
startLine?: number
|
|
||||||
endLine?: number
|
|
||||||
}) => void
|
|
||||||
children: string
|
children: string
|
||||||
isApplying: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const blocks: ParsedInfioBlock[] = useMemo(
|
const blocks: ParsedMsgBlock[] = useMemo(
|
||||||
() => parseinfioBlocks(children),
|
() => parseMsgBlocks(children),
|
||||||
[children],
|
[children],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{blocks.map((block, index) =>
|
{blocks.map((block, index) =>
|
||||||
block.type === 'string' ? (
|
block.type === 'thinking' ? (
|
||||||
<Markdown key={index} className="infio-markdown">
|
<Markdown key={"markdown-" + index} className="infio-markdown">
|
||||||
{block.content}
|
{block.content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
) : block.type === 'think' ? (
|
) : block.type === 'think' ? (
|
||||||
<MarkdownReasoningBlock
|
<MarkdownReasoningBlock
|
||||||
key={index}
|
key={"reasoning-" + index}
|
||||||
reasoningContent={block.content}
|
reasoningContent={block.content}
|
||||||
/>
|
/>
|
||||||
) : block.startLine && block.endLine && block.filename && block.action === InfioBlockAction.Reference ? (
|
) : block.type === 'write_to_file' ? (
|
||||||
<MarkdownReferenceBlock
|
<MarkdownEditFileBlock
|
||||||
key={index}
|
key={"write-to-file-" + index}
|
||||||
filename={block.filename}
|
applyStatus={applyStatus}
|
||||||
startLine={block.startLine}
|
mode={block.type}
|
||||||
endLine={block.endLine}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<MarkdownActionBlock
|
|
||||||
key={index}
|
|
||||||
onApply={onApply}
|
onApply={onApply}
|
||||||
isApplying={isApplying}
|
path={block.path}
|
||||||
language={block.language}
|
startLine={1}
|
||||||
filename={block.filename}
|
|
||||||
startLine={block.startLine}
|
|
||||||
endLine={block.endLine}
|
|
||||||
action={block.action}
|
|
||||||
>
|
>
|
||||||
{block.content}
|
{block.content}
|
||||||
</MarkdownActionBlock>
|
</MarkdownEditFileBlock>
|
||||||
|
) : block.type === 'insert_content' ? (
|
||||||
|
<MarkdownEditFileBlock
|
||||||
|
key={"insert-content-" + index}
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
mode={block.type}
|
||||||
|
onApply={onApply}
|
||||||
|
path={block.path}
|
||||||
|
startLine={block.startLine}
|
||||||
|
endLine={block.startLine} // 插入内容时,endLine 和 startLine 相同
|
||||||
|
>
|
||||||
|
{block.content}
|
||||||
|
</MarkdownEditFileBlock>
|
||||||
|
) : block.type === 'search_and_replace' ? (
|
||||||
|
<MarkdownSearchAndReplace
|
||||||
|
key={"search-and-replace-" + index}
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
onApply={onApply}
|
||||||
|
path={block.path}
|
||||||
|
operations={block.operations.map(op => ({
|
||||||
|
search: op.search,
|
||||||
|
replace: op.replace,
|
||||||
|
startLine: op.start_line,
|
||||||
|
endLine: op.end_line,
|
||||||
|
useRegex: op.use_regex,
|
||||||
|
ignoreCase: op.ignore_case,
|
||||||
|
regexFlags: op.regex_flags,
|
||||||
|
}))}
|
||||||
|
finish={block.finish}
|
||||||
|
/>
|
||||||
|
) : block.type === 'read_file' ? (
|
||||||
|
<MarkdownReadFileBlock
|
||||||
|
key={"read-file-" + index}
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
onApply={onApply}
|
||||||
|
path={block.path}
|
||||||
|
finish={block.finish}
|
||||||
|
/>
|
||||||
|
) : block.type === 'list_files' ? (
|
||||||
|
<MarkdownListFilesBlock
|
||||||
|
key={"list-files-" + index}
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
onApply={onApply}
|
||||||
|
path={block.path}
|
||||||
|
recursive={block.recursive}
|
||||||
|
finish={block.finish}
|
||||||
|
/>
|
||||||
|
) : block.type === 'regex_search_files' ? (
|
||||||
|
<MarkdownRegexSearchFilesBlock
|
||||||
|
key={"regex-search-files-" + index}
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
onApply={onApply}
|
||||||
|
path={block.path}
|
||||||
|
regex={block.regex}
|
||||||
|
finish={block.finish}
|
||||||
|
/>
|
||||||
|
) : block.type === 'semantic_search_files' ? (
|
||||||
|
<MarkdownSemanticSearchFilesBlock
|
||||||
|
key={"semantic-search-files-" + index}
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
onApply={onApply}
|
||||||
|
path={block.path}
|
||||||
|
query={block.query}
|
||||||
|
finish={block.finish}
|
||||||
|
/>
|
||||||
|
) : block.type === 'attempt_completion' ? (
|
||||||
|
<MarkdownWithIcons
|
||||||
|
key={"attempt-completion-" + index}
|
||||||
|
className="infio-markdown infio-attempt-completion"
|
||||||
|
markdownContent={
|
||||||
|
`<icon name='attempt_completion' size={14} className="infio-markdown-icon" />
|
||||||
|
${block.result && block.result.trimStart()}`} />
|
||||||
|
) : block.type === 'ask_followup_question' ? (
|
||||||
|
<MarkdownWithIcons
|
||||||
|
key={"ask-followup-question-" + index}
|
||||||
|
className="infio-markdown infio-followup-question"
|
||||||
|
markdownContent={
|
||||||
|
`<icon name='ask_followup_question' size={14} className="infio-markdown-icon" />
|
||||||
|
${block.question && block.question.trimStart()}`} />
|
||||||
|
) : (
|
||||||
|
<Markdown key={"markdown-" + index} className="infio-markdown">
|
||||||
|
{block.content}
|
||||||
|
</Markdown>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -26,8 +26,8 @@ import OnEnterPlugin from './plugins/on-enter/OnEnterPlugin'
|
|||||||
import OnMutationPlugin, {
|
import OnMutationPlugin, {
|
||||||
NodeMutations,
|
NodeMutations,
|
||||||
} from './plugins/on-mutation/OnMutationPlugin'
|
} from './plugins/on-mutation/OnMutationPlugin'
|
||||||
import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin'
|
// import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin'
|
||||||
import TemplatePlugin from './plugins/template/TemplatePlugin'
|
// import TemplatePlugin from './plugins/template/TemplatePlugin'
|
||||||
|
|
||||||
export type LexicalContentEditableProps = {
|
export type LexicalContentEditableProps = {
|
||||||
editorRef: RefObject<LexicalEditor>
|
editorRef: RefObject<LexicalEditor>
|
||||||
@ -141,13 +141,13 @@ export default function LexicalContentEditable({
|
|||||||
<AutoLinkMentionPlugin />
|
<AutoLinkMentionPlugin />
|
||||||
<ImagePastePlugin onCreateImageMentionables={onCreateImageMentionables} />
|
<ImagePastePlugin onCreateImageMentionables={onCreateImageMentionables} />
|
||||||
<DragDropPaste onCreateImageMentionables={onCreateImageMentionables} />
|
<DragDropPaste onCreateImageMentionables={onCreateImageMentionables} />
|
||||||
<TemplatePlugin />
|
{/* <TemplatePlugin /> */}
|
||||||
{plugins?.templatePopover && (
|
{/* {plugins?.templatePopover && (
|
||||||
<CreateTemplatePopoverPlugin
|
<CreateTemplatePopoverPlugin
|
||||||
anchorElement={plugins.templatePopover.anchorElement}
|
anchorElement={plugins.templatePopover.anchorElement}
|
||||||
contentEditableElement={contentEditableRef.current}
|
contentEditableElement={contentEditableRef.current}
|
||||||
/>
|
/>
|
||||||
)}
|
)} */}
|
||||||
</LexicalComposer>
|
</LexicalComposer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -265,9 +265,6 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
|
|||||||
handleSubmit({ useVaultSearch: true })
|
handleSubmit({ useVaultSearch: true })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
templatePopover: {
|
|
||||||
anchorElement: containerRef.current,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,146 +0,0 @@
|
|||||||
import { $generateJSONFromSelectedNodes } from '@lexical/clipboard'
|
|
||||||
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
|
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
||||||
import * as Dialog from '@radix-ui/react-dialog'
|
|
||||||
import {
|
|
||||||
$getSelection,
|
|
||||||
COMMAND_PRIORITY_LOW,
|
|
||||||
SELECTION_CHANGE_COMMAND,
|
|
||||||
} from 'lexical'
|
|
||||||
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
import CreateTemplateDialogContent from '../../../CreateTemplateDialog'
|
|
||||||
|
|
||||||
export default function CreateTemplatePopoverPlugin({
|
|
||||||
anchorElement,
|
|
||||||
contentEditableElement,
|
|
||||||
}: {
|
|
||||||
anchorElement: HTMLElement | null
|
|
||||||
contentEditableElement: HTMLElement | null
|
|
||||||
}): JSX.Element | null {
|
|
||||||
const [editor] = useLexicalComposerContext()
|
|
||||||
|
|
||||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties | null>(null)
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
|
||||||
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<
|
|
||||||
BaseSerializedNode[] | null
|
|
||||||
>(null)
|
|
||||||
|
|
||||||
const popoverRef = useRef<HTMLButtonElement>(null)
|
|
||||||
|
|
||||||
const getSelectedSerializedNodes = useCallback(():
|
|
||||||
| BaseSerializedNode[]
|
|
||||||
| null => {
|
|
||||||
if (!editor) return null
|
|
||||||
let selectedNodes: BaseSerializedNode[] | null = null
|
|
||||||
editor.update(() => {
|
|
||||||
const selection = $getSelection()
|
|
||||||
if (!selection) return
|
|
||||||
selectedNodes = $generateJSONFromSelectedNodes(editor, selection).nodes
|
|
||||||
if (selectedNodes.length === 0) return null
|
|
||||||
})
|
|
||||||
return selectedNodes
|
|
||||||
}, [editor])
|
|
||||||
|
|
||||||
const updatePopoverPosition = useCallback(() => {
|
|
||||||
if (!anchorElement || !contentEditableElement) return
|
|
||||||
const nativeSelection = document.getSelection()
|
|
||||||
const range = nativeSelection?.getRangeAt(0)
|
|
||||||
if (!range || range.collapsed) {
|
|
||||||
setIsPopoverOpen(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!contentEditableElement.contains(range.commonAncestorContainer)) {
|
|
||||||
setIsPopoverOpen(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const rects = Array.from(range.getClientRects())
|
|
||||||
if (rects.length === 0) {
|
|
||||||
setIsPopoverOpen(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const anchorRect = anchorElement.getBoundingClientRect()
|
|
||||||
const idealLeft = rects[rects.length - 1].right - anchorRect.left
|
|
||||||
const paddingX = 8
|
|
||||||
const paddingY = 4
|
|
||||||
const minLeft = (popoverRef.current?.offsetWidth ?? 0) + paddingX
|
|
||||||
const finalLeft = Math.max(minLeft, idealLeft)
|
|
||||||
setPopoverStyle({
|
|
||||||
top: rects[rects.length - 1].bottom - anchorRect.top + paddingY,
|
|
||||||
left: finalLeft,
|
|
||||||
transform: 'translate(-100%, 0)',
|
|
||||||
})
|
|
||||||
setIsPopoverOpen(true)
|
|
||||||
}, [anchorElement, contentEditableElement])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const removeSelectionChangeListener = editor.registerCommand(
|
|
||||||
SELECTION_CHANGE_COMMAND,
|
|
||||||
() => {
|
|
||||||
updatePopoverPosition()
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
COMMAND_PRIORITY_LOW,
|
|
||||||
)
|
|
||||||
return () => {
|
|
||||||
removeSelectionChangeListener()
|
|
||||||
}
|
|
||||||
}, [editor, updatePopoverPosition])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Update popover position when the content is cleared
|
|
||||||
// (Selection change event doesn't fire in this case)
|
|
||||||
if (!isPopoverOpen) return
|
|
||||||
const removeTextContentChangeListener = editor.registerTextContentListener(
|
|
||||||
() => {
|
|
||||||
updatePopoverPosition()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return () => {
|
|
||||||
removeTextContentChangeListener()
|
|
||||||
}
|
|
||||||
}, [editor, isPopoverOpen, updatePopoverPosition])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contentEditableElement) return
|
|
||||||
const handleScroll = () => {
|
|
||||||
updatePopoverPosition()
|
|
||||||
}
|
|
||||||
contentEditableElement.addEventListener('scroll', handleScroll)
|
|
||||||
return () => {
|
|
||||||
contentEditableElement.removeEventListener('scroll', handleScroll)
|
|
||||||
}
|
|
||||||
}, [contentEditableElement, updatePopoverPosition])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root
|
|
||||||
modal={false}
|
|
||||||
open={isDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (open) {
|
|
||||||
setSelectedSerializedNodes(getSelectedSerializedNodes())
|
|
||||||
}
|
|
||||||
setIsDialogOpen(open)
|
|
||||||
setIsPopoverOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<button
|
|
||||||
ref={popoverRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
visibility: isPopoverOpen ? 'visible' : 'hidden',
|
|
||||||
...popoverStyle,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create template
|
|
||||||
</button>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<CreateTemplateDialogContent
|
|
||||||
selectedSerializedNodes={selectedSerializedNodes}
|
|
||||||
onClose={() => setIsDialogOpen(false)}
|
|
||||||
/>
|
|
||||||
</Dialog.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import {
|
|
||||||
$parseSerializedNode,
|
|
||||||
COMMAND_PRIORITY_NORMAL,
|
|
||||||
TextNode,
|
|
||||||
} from 'lexical'
|
|
||||||
import { Trash2 } from 'lucide-react'
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
|
|
||||||
import { useDatabase } from '../../../../../contexts/DatabaseContext'
|
|
||||||
import { SelectTemplate } from '../../../../../database/schema'
|
|
||||||
import { MenuOption } from '../shared/LexicalMenu'
|
|
||||||
import {
|
|
||||||
LexicalTypeaheadMenuPlugin,
|
|
||||||
useBasicTypeaheadTriggerMatch,
|
|
||||||
} from '../typeahead-menu/LexicalTypeaheadMenuPlugin'
|
|
||||||
|
|
||||||
class TemplateTypeaheadOption extends MenuOption {
|
|
||||||
name: string
|
|
||||||
template: SelectTemplate
|
|
||||||
|
|
||||||
constructor(name: string, template: SelectTemplate) {
|
|
||||||
super(name)
|
|
||||||
this.name = name
|
|
||||||
this.template = template
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplateMenuItem({
|
|
||||||
index,
|
|
||||||
isSelected,
|
|
||||||
onClick,
|
|
||||||
onDelete,
|
|
||||||
onMouseEnter,
|
|
||||||
option,
|
|
||||||
}: {
|
|
||||||
index: number
|
|
||||||
isSelected: boolean
|
|
||||||
onClick: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
onMouseEnter: () => void
|
|
||||||
option: TemplateTypeaheadOption
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={option.key}
|
|
||||||
tabIndex={-1}
|
|
||||||
className={clsx('item', isSelected && 'selected')}
|
|
||||||
ref={(el) => option.setRefElement(el)}
|
|
||||||
role="option"
|
|
||||||
aria-selected={isSelected}
|
|
||||||
id={`typeahead-item-${index}`}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<div className="infio-chat-template-menu-item">
|
|
||||||
<div className="text">{option.name}</div>
|
|
||||||
<div
|
|
||||||
onClick={(evt) => {
|
|
||||||
evt.stopPropagation()
|
|
||||||
evt.preventDefault()
|
|
||||||
onDelete()
|
|
||||||
}}
|
|
||||||
className="infio-chat-template-menu-item-delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TemplatePlugin() {
|
|
||||||
const [editor] = useLexicalComposerContext()
|
|
||||||
const { getTemplateManager } = useDatabase()
|
|
||||||
|
|
||||||
const [queryString, setQueryString] = useState<string | null>(null)
|
|
||||||
const [searchResults, setSearchResults] = useState<SelectTemplate[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (queryString == null) return
|
|
||||||
getTemplateManager().then((templateManager) =>
|
|
||||||
templateManager.searchTemplates(queryString).then(setSearchResults),
|
|
||||||
)
|
|
||||||
}, [queryString, getTemplateManager])
|
|
||||||
|
|
||||||
const options = useMemo(
|
|
||||||
() =>
|
|
||||||
searchResults.map(
|
|
||||||
(result) => new TemplateTypeaheadOption(result.name, result),
|
|
||||||
),
|
|
||||||
[searchResults],
|
|
||||||
)
|
|
||||||
|
|
||||||
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
|
||||||
minLength: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSelectOption = useCallback(
|
|
||||||
(
|
|
||||||
selectedOption: TemplateTypeaheadOption,
|
|
||||||
nodeToRemove: TextNode | null,
|
|
||||||
closeMenu: () => void,
|
|
||||||
) => {
|
|
||||||
editor.update(() => {
|
|
||||||
const parsedNodes = selectedOption.template.content.nodes.map((node) =>
|
|
||||||
$parseSerializedNode(node),
|
|
||||||
)
|
|
||||||
if (nodeToRemove) {
|
|
||||||
const parent = nodeToRemove.getParentOrThrow()
|
|
||||||
parent.splice(nodeToRemove.getIndexWithinParent(), 1, parsedNodes)
|
|
||||||
const lastNode = parsedNodes[parsedNodes.length - 1]
|
|
||||||
lastNode.selectEnd()
|
|
||||||
}
|
|
||||||
closeMenu()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
async (option: TemplateTypeaheadOption) => {
|
|
||||||
await (await getTemplateManager()).deleteTemplate(option.template.id)
|
|
||||||
if (queryString !== null) {
|
|
||||||
const updatedResults = await (
|
|
||||||
await getTemplateManager()
|
|
||||||
).searchTemplates(queryString)
|
|
||||||
setSearchResults(updatedResults)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[getTemplateManager, queryString],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LexicalTypeaheadMenuPlugin<TemplateTypeaheadOption>
|
|
||||||
onQueryChange={setQueryString}
|
|
||||||
onSelectOption={onSelectOption}
|
|
||||||
triggerFn={checkForTriggerMatch}
|
|
||||||
options={options}
|
|
||||||
commandPriority={COMMAND_PRIORITY_NORMAL}
|
|
||||||
menuRenderFn={(
|
|
||||||
anchorElementRef,
|
|
||||||
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
|
|
||||||
) =>
|
|
||||||
anchorElementRef.current && searchResults.length
|
|
||||||
? createPortal(
|
|
||||||
<div
|
|
||||||
className="infio-popover"
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ul>
|
|
||||||
{options.map((option, i: number) => (
|
|
||||||
<TemplateMenuItem
|
|
||||||
index={i}
|
|
||||||
isSelected={selectedIndex === i}
|
|
||||||
onClick={() => {
|
|
||||||
setHighlightedIndex(i)
|
|
||||||
selectOptionAndCleanUp(option)
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
handleDelete(option)
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHighlightedIndex(i)
|
|
||||||
}}
|
|
||||||
key={option.key}
|
|
||||||
option={option}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>,
|
|
||||||
anchorElementRef.current,
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user