diff --git a/src/components/chat-view/Chat.tsx b/src/components/chat-view/Chat.tsx index 214aa79..55a0aa8 100644 --- a/src/components/chat-view/Chat.tsx +++ b/src/components/chat-view/Chat.tsx @@ -31,6 +31,10 @@ import { LLMAPIKeyInvalidException, LLMAPIKeyNotSetException, } from '../../utils/llm/exception' +import { + getMentionableKey, + serializeMentionable, +} from '../../utils/mentionable' import { readTFileContent } from '../../utils/obsidian' import { PromptGenerator } from '../../utils/promptGenerator' @@ -98,6 +102,16 @@ const Chat = forwardRef((props, ref) => { } return newMessage }) + const [addedBlockKey, setAddedBlockKey] = useState( + props.selectedBlock + ? getMentionableKey( + serializeMentionable({ + type: 'block', + ...props.selectedBlock, + }), + ) + : null, + ) const [chatMessages, setChatMessages] = useState([]) const [focusedMessageId, setFocusedMessageId] = useState(null) const [currentConversationId, setCurrentConversationId] = @@ -404,6 +418,8 @@ const Chat = forwardRef((props, ref) => { ...data, } + setAddedBlockKey(getMentionableKey(serializeMentionable(mentionable))) + if (focusedMessageId === inputMessage.id) { setInputMessage((prevInputMessage) => ({ ...prevInputMessage, @@ -553,6 +569,7 @@ const Chat = forwardRef((props, ref) => { })) }} autoFocus + addedBlockKey={addedBlockKey} /> ) diff --git a/src/components/chat-view/MarkdownReferenceBlock.tsx b/src/components/chat-view/MarkdownReferenceBlock.tsx index bad20c1..fb8f602 100644 --- a/src/components/chat-view/MarkdownReferenceBlock.tsx +++ b/src/components/chat-view/MarkdownReferenceBlock.tsx @@ -1,9 +1,8 @@ -import { MarkdownView } from 'obsidian' import { PropsWithChildren, useEffect, useMemo, useState } from 'react' import { useApp } from '../../contexts/app-context' import { useDarkModeContext } from '../../contexts/dark-mode-context' -import { readTFileContent } from '../../utils/obsidian' +import { openMarkdownFile, readTFileContent } from '../../utils/obsidian' import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper' @@ -45,27 +44,7 @@ export default function MarkdownReferenceBlock({ }, [filename, startLine, endLine, app.vault]) const handleClick = () => { - const file = app.vault.getFileByPath(filename) - if (!file) return - - const existingLeaf = app.workspace - .getLeavesOfType('markdown') - .find( - (leaf) => - leaf.view instanceof MarkdownView && - leaf.view.file?.path === file.path, - ) - - if (existingLeaf) { - app.workspace.setActiveLeaf(existingLeaf, { focus: true }) - const view = existingLeaf.view as MarkdownView - view.setEphemeralState({ line: startLine }) - } else { - const leaf = app.workspace.getLeaf('tab') - leaf.openFile(file, { - eState: { line: startLine }, - }) - } + openMarkdownFile(app, filename, startLine) } // TODO: Update styles diff --git a/src/components/chat-view/chat-input/ChatUserInput.tsx b/src/components/chat-view/chat-input/ChatUserInput.tsx index 9116991..17dc3ee 100644 --- a/src/components/chat-view/chat-input/ChatUserInput.tsx +++ b/src/components/chat-view/chat-input/ChatUserInput.tsx @@ -9,6 +9,7 @@ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' +import { useQuery } from '@tanstack/react-query' import { $nodesOfType, LexicalEditor, SerializedEditorState } from 'lexical' import { forwardRef, @@ -16,6 +17,7 @@ import { useEffect, useImperativeHandle, useRef, + useState, } from 'react' import { deserializeMentionable, @@ -23,9 +25,12 @@ import { } from 'src/utils/mentionable' import { useApp } from '../../../contexts/app-context' +import { useDarkModeContext } from '../../../contexts/dark-mode-context' import { Mentionable, SerializedMentionable } from '../../../types/mentionable' import { fuzzySearch } from '../../../utils/fuzzy-search' import { getMentionableKey } from '../../../utils/mentionable' +import { openMarkdownFile, readTFileContent } from '../../../utils/obsidian' +import { MemoizedSyntaxHighlighterWrapper } from '../SyntaxHighlighterWrapper' import MentionableBadge from './MentionableBadge' import { ModelSelect } from './ModelSelect' @@ -54,6 +59,7 @@ export type ChatUserInputProps = { mentionables: Mentionable[] setMentionables: (mentionables: Mentionable[]) => void autoFocus?: boolean + addedBlockKey?: string | null } const ChatUserInput = forwardRef( @@ -66,15 +72,27 @@ const ChatUserInput = forwardRef( mentionables, setMentionables, autoFocus = false, + addedBlockKey, }, ref, ) => { const app = useApp() + const { isDarkMode } = useDarkModeContext() const editorRef = useRef(null) const contentEditableRef = useRef(null) const updaterRef = useRef(null) + const [displayedMentionableKey, setDisplayedMentionableKey] = useState< + string | null + >(addedBlockKey ?? null) + + useEffect(() => { + if (addedBlockKey) { + setDisplayedMentionableKey(addedBlockKey) + } + }, [addedBlockKey]) + useImperativeHandle(ref, () => ({ focus: () => { contentEditableRef.current?.focus() @@ -121,6 +139,7 @@ const ChatUserInput = forwardRef( return } setMentionables([...mentionables, mentionable]) + setDisplayedMentionableKey(mentionableKey) } const handleMentionNodeMutation = ( @@ -197,6 +216,48 @@ const ChatUserInput = forwardRef( }) } + const { data: fileContent } = useQuery({ + queryKey: [ + 'file', + displayedMentionableKey, + mentionables.map((m) => getMentionableKey(serializeMentionable(m))), // should be updated when mentionables change (especially on delete) + ], + queryFn: async () => { + if (!displayedMentionableKey) return null + + const displayedMentionable = mentionables.find( + (m) => + getMentionableKey(serializeMentionable(m)) === + displayedMentionableKey, + ) + + if (!displayedMentionable) return null + + if ( + displayedMentionable.type === 'file' || + displayedMentionable.type === 'current-file' + ) { + if (!displayedMentionable.file) return null + return await readTFileContent(displayedMentionable.file, app.vault) + } else if (displayedMentionable.type === 'block') { + const fileContent = await readTFileContent( + displayedMentionable.file, + app.vault, + ) + + return fileContent + .split('\n') + .slice( + displayedMentionable.startLine - 1, + displayedMentionable.endLine, + ) + .join('\n') + } + + return null + }, + }) + const handleSubmit = (useVaultSearch?: boolean) => { const content = editorRef.current?.getEditorState()?.toJSON() content && onSubmit(content, useVaultSearch) @@ -211,11 +272,41 @@ const ChatUserInput = forwardRef( key={getMentionableKey(serializeMentionable(m))} mentionable={m} onDelete={() => handleMentionableDelete(m)} + onClick={() => { + const mentionableKey = getMentionableKey( + serializeMentionable(m), + ) + if ( + (m.type === 'current-file' || + m.type === 'file' || + m.type === 'block') && + m.file && + mentionableKey === displayedMentionableKey + ) { + // open file on click again + openMarkdownFile(app, m.file.path) + } else { + setDisplayedMentionableKey(mentionableKey) + } + }} /> ))} )} + {fileContent && ( +
+ + {fileContent} + +
+ )} + {/* There was two approach to make mentionable node copy and pasteable. diff --git a/src/components/chat-view/chat-input/MentionableBadge.tsx b/src/components/chat-view/chat-input/MentionableBadge.tsx index 8a129b4..e859325 100644 --- a/src/components/chat-view/chat-input/MentionableBadge.tsx +++ b/src/components/chat-view/chat-input/MentionableBadge.tsx @@ -15,15 +15,20 @@ import { getMentionableIcon } from './utils/get-metionable-icon' function BadgeBase({ children, onDelete, + onClick, }: PropsWithChildren<{ onDelete: () => void + onClick: () => void }>) { return ( -
+
{children}
{ + evt.stopPropagation() + onDelete() + }} >
@@ -34,13 +39,15 @@ function BadgeBase({ function FileBadge({ mentionable, onDelete, + onClick, }: { mentionable: MentionableFile onDelete: () => void + onClick: () => void }) { const Icon = getMentionableIcon(mentionable) return ( - +
{Icon && ( void + onClick: () => void }) { const Icon = getMentionableIcon(mentionable) return ( - + {/* TODO: Update style */}
{Icon && ( @@ -82,13 +91,15 @@ function VaultBadge({ // eslint-disable-next-line @typescript-eslint/no-unused-vars mentionable, onDelete, + onClick, }: { mentionable: MentionableVault onDelete: () => void + onClick: () => void }) { const Icon = getMentionableIcon(mentionable) return ( - + {/* TODO: Update style */}
{Icon && ( @@ -106,13 +117,15 @@ function VaultBadge({ function CurrentFileBadge({ mentionable, onDelete, + onClick, }: { mentionable: MentionableCurrentFile onDelete: () => void + onClick: () => void }) { const Icon = getMentionableIcon(mentionable) return mentionable.file ? ( - +
{Icon && ( void + onClick: () => void }) { const Icon = getMentionableIcon(mentionable) return ( - +
{Icon && ( void + onClick: () => void }) { switch (mentionable.type) { case 'file': - return + return ( + + ) case 'folder': - return + return ( + + ) case 'vault': - return + return ( + + ) case 'current-file': - return + return ( + + ) case 'block': - return + return ( + + ) } } diff --git a/src/utils/obsidian.ts b/src/utils/obsidian.ts index 0d8ca19..d0e7a61 100644 --- a/src/utils/obsidian.ts +++ b/src/utils/obsidian.ts @@ -51,8 +51,8 @@ export async function getMentionableBlockData( return { content: selectionContent, file, - startLine, - endLine, + startLine: startLine + 1, // +1 because startLine is 0-indexed + endLine: endLine + 1, // +1 because startLine is 0-indexed } } @@ -92,3 +92,33 @@ export function calculateFileDistance( return distance } + +export function openMarkdownFile( + app: App, + filePath: string, + startLine?: number, +) { + const file = app.vault.getFileByPath(filePath) + if (!file) return + + const existingLeaf = app.workspace + .getLeavesOfType('markdown') + .find( + (leaf) => + leaf.view instanceof MarkdownView && leaf.view.file?.path === file.path, + ) + + if (existingLeaf) { + app.workspace.setActiveLeaf(existingLeaf, { focus: true }) + + if (startLine) { + const view = existingLeaf.view as MarkdownView + view.setEphemeralState({ line: startLine }) + } + } else { + const leaf = app.workspace.getLeaf('tab') + leaf.openFile(file, { + eState: startLine ? { line: startLine } : undefined, + }) + } +} diff --git a/styles.css b/styles.css index 4bf51ba..d589b19 100644 --- a/styles.css +++ b/styles.css @@ -258,6 +258,11 @@ button:not(.clickable-icon).smtcmp-chat-list-dropdown { white-space: nowrap; } +.smtcmp-chat-user-input-file-badge:hover { + background-color: var(--background-modifier-hover); + cursor: pointer; +} + .smtcmp-chat-user-input-file-badge-delete { cursor: pointer; display: flex; @@ -292,6 +297,15 @@ button:not(.clickable-icon).smtcmp-chat-list-dropdown { color: var(--color-base-50); } +.smtcmp-chat-user-input-file-content-preview { + background-color: var(--background-primary); + border-radius: var(--radius-s); + border: 1px solid var(--background-modifier-border); + max-height: 350px; + overflow-y: auto; + white-space: pre-line; +} + /** * ChatUserInput */