From 82490108c541d09a952fddefa9b8bf393aa71267 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Mon, 16 Sep 2024 17:07:31 -0400 Subject: [PATCH] Get context mentions UI working --- webview-ui/src/components/ChatTextArea.tsx | 278 ++++++++++++++++++++- webview-ui/src/components/ContextMenu.tsx | 94 +++++++ webview-ui/src/index.css | 12 + webview-ui/src/utils/mention-context.ts | 122 +++++++++ 4 files changed, 495 insertions(+), 11 deletions(-) create mode 100644 webview-ui/src/components/ContextMenu.tsx create mode 100644 webview-ui/src/utils/mention-context.ts diff --git a/webview-ui/src/components/ChatTextArea.tsx b/webview-ui/src/components/ChatTextArea.tsx index c500418..aaaa848 100644 --- a/webview-ui/src/components/ChatTextArea.tsx +++ b/webview-ui/src/components/ChatTextArea.tsx @@ -1,7 +1,9 @@ -import React, { forwardRef, useState, useCallback, useEffect } from "react" +import React, { forwardRef, useCallback, useEffect, useRef, useState, useLayoutEffect } from "react" import DynamicTextArea from "react-textarea-autosize" -import Thumbnails from "./Thumbnails" +import { insertMention, shouldShowContextMenu, getContextMenuOptions, removeMention } from "../utils/mention-context" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" +import ContextMenu from "./ContextMenu" +import Thumbnails from "./Thumbnails" interface ChatTextAreaProps { inputValue: string @@ -35,18 +37,173 @@ const ChatTextArea = forwardRef( const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [thumbnailsHeight, setThumbnailsHeight] = useState(0) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) + const [showContextMenu, setShowContextMenu] = useState(false) + const [cursorPosition, setCursorPosition] = useState(0) + const [searchQuery, setSearchQuery] = useState("") + const containerRef = useRef(null) + const textAreaRef = useRef(null) + const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false) + const highlightLayerRef = useRef(null) + const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1) + const [selectedType, setSelectedType] = useState(null) + const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false) + const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) + const handleMentionSelect = useCallback( + (type: string, value: string) => { + if (value === "File" || value === "Folder") { + setSelectedType(type.toLowerCase()) + setSearchQuery("") + setSelectedMenuIndex(0) + return + } + + setShowContextMenu(false) + setSelectedType(null) + if (textAreaRef.current) { + let insertValue = value + if (type === "url") { + // For URLs, we insert the value as is + insertValue = value + } else if (type === "file" || type === "folder") { + // For files and folders, we insert the path + insertValue = value + } + + const newValue = insertMention(textAreaRef.current.value, cursorPosition, insertValue) + setInputValue(newValue) + const newCursorPosition = newValue.indexOf(" ", newValue.lastIndexOf("@")) + 1 + setCursorPosition(newCursorPosition) + setIntendedCursorPosition(newCursorPosition) // Update intended cursor position + textAreaRef.current.focus() + // Remove the direct setSelectionRange call + // textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition) + } + }, + [setInputValue, cursorPosition] + ) const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { + if (showContextMenu) { + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault() + setSelectedMenuIndex((prevIndex) => { + const direction = event.key === "ArrowUp" ? -1 : 1 + let newIndex = prevIndex + direction + const options = getContextMenuOptions(searchQuery, selectedType) + const optionsLength = options.length + + if (newIndex < 0) newIndex = optionsLength - 1 + if (newIndex >= optionsLength) newIndex = 0 + + while (options[newIndex]?.type === "url") { + newIndex = (newIndex + direction + optionsLength) % optionsLength + } + + return newIndex + }) + return + } + if (event.key === "Enter" && selectedMenuIndex !== -1) { + event.preventDefault() + const selectedOption = getContextMenuOptions(searchQuery, selectedType)[selectedMenuIndex] + if (selectedOption && selectedOption.type !== "url") { + handleMentionSelect(selectedOption.type, selectedOption.value) + } + return + } + } + const isComposing = event.nativeEvent?.isComposing ?? false if (event.key === "Enter" && !event.shiftKey && !isComposing) { event.preventDefault() onSend() } + + if (event.key === "Backspace" && !isComposing) { + const charBeforeCursor = inputValue[cursorPosition - 1] + const charAfterCursor = inputValue[cursorPosition + 1] + + const charBeforeIsWhitespace = + charBeforeCursor === " " || charBeforeCursor === "\n" || charBeforeCursor === "\r\n" + const charAfterIsWhitespace = + charAfterCursor === " " || charAfterCursor === "\n" || charAfterCursor === "\r\n" + if ( + charBeforeIsWhitespace && + inputValue.slice(0, cursorPosition - 1).match(/@(\/|\w+:\/\/)[^\s]+$/) + ) { + const newCursorPosition = cursorPosition - 1 + if (!charAfterIsWhitespace) { + event.preventDefault() + textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition) + setCursorPosition(newCursorPosition) + } + setCursorPosition(newCursorPosition) + setJustDeletedSpaceAfterMention(true) + } else if (justDeletedSpaceAfterMention) { + const { newText, newPosition } = removeMention(inputValue, cursorPosition) + if (newText !== inputValue) { + event.preventDefault() + setInputValue(newText) + setIntendedCursorPosition(newPosition) // Store the new cursor position in state + } + setJustDeletedSpaceAfterMention(false) + setShowContextMenu(false) + } else { + setJustDeletedSpaceAfterMention(false) + } + } }, - [onSend] + [ + onSend, + showContextMenu, + searchQuery, + selectedMenuIndex, + handleMentionSelect, + selectedType, + inputValue, + cursorPosition, + setInputValue, + justDeletedSpaceAfterMention, + ] ) + useLayoutEffect(() => { + if (intendedCursorPosition !== null && textAreaRef.current) { + textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition) + setIntendedCursorPosition(null) // Reset the state + } + }, [inputValue, intendedCursorPosition]) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value + const newCursorPosition = e.target.selectionStart + setInputValue(newValue) + setCursorPosition(newCursorPosition) + const showMenu = shouldShowContextMenu(newValue, newCursorPosition) + + setShowContextMenu(showMenu) + if (showMenu) { + const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1) + setSearchQuery(newValue.slice(lastAtIndex + 1, newCursorPosition)) + setSelectedMenuIndex(2) // Set to "File" option by default + } else { + setSearchQuery("") + setSelectedMenuIndex(-1) + } + }, + [setInputValue] + ) + + const handleBlur = useCallback(() => { + // Only hide the context menu if the user didn't click on it + if (!isMouseDownOnMenu) { + setShowContextMenu(false) + } + setIsTextAreaFocused(false) + }, [isMouseDownOnMenu]) + const handlePaste = useCallback( async (e: React.ClipboardEvent) => { const items = e.clipboardData.items @@ -100,14 +257,64 @@ const ChatTextArea = forwardRef( } }, [selectedImages]) + const handleMenuMouseDown = useCallback(() => { + setIsMouseDownOnMenu(true) + }, []) + + const updateHighlights = useCallback(() => { + if (!textAreaRef.current || !highlightLayerRef.current) return + + const text = textAreaRef.current.value + const mentionRegex = /@(\/|\w+:\/\/)[^\s]+/g + + highlightLayerRef.current.innerHTML = text + .replace(/\n$/, "\n\n") + .replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" }[c] || c)) + .replace(mentionRegex, '$&') + + highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop + highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft + }, []) + + useLayoutEffect(() => { + updateHighlights() + }, [inputValue, updateHighlights]) + + const updateCursorPosition = useCallback(() => { + if (textAreaRef.current) { + setCursorPosition(textAreaRef.current.selectionStart) + } + }, []) + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => { + if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { + updateCursorPosition() + } + }, + [updateCursorPosition] + ) + return (
+ {showContextMenu && ( + + )} {!isTextAreaFocused && (
( border: "1px solid var(--vscode-input-border)", borderRadius: 2, pointerEvents: "none", + zIndex: 5, }} /> )} +
{ + if (typeof ref === "function") { + ref(el) + } else if (ref) { + ref.current = el + } + textAreaRef.current = el + }} value={inputValue} disabled={textAreaDisabled} - onChange={(e) => setInputValue(e.target.value)} + onChange={(e) => { + handleInputChange(e) + updateHighlights() + }} onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} onFocus={() => setIsTextAreaFocused(true)} - onBlur={() => setIsTextAreaFocused(false)} + onBlur={handleBlur} onPaste={handlePaste} + onSelect={updateCursorPosition} + onMouseUp={updateCursorPosition} onHeightChange={(height) => { if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { setTextAreaBaseHeight(height) @@ -140,7 +387,7 @@ const ChatTextArea = forwardRef( style={{ width: "100%", boxSizing: "border-box", - backgroundColor: "var(--vscode-input-background)", + backgroundColor: "transparent", color: "var(--vscode-input-foreground)", //border: "1px solid var(--vscode-input-border)", borderRadius: 2, @@ -148,19 +395,26 @@ const ChatTextArea = forwardRef( fontSize: "var(--vscode-editor-font-size)", lineHeight: "var(--vscode-editor-line-height)", resize: "none", - overflow: "hidden", + overflowX: "hidden", + overflowY: "scroll", + scrollbarWidth: "none", // Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410) - borderTop: "9px solid transparent", - borderBottom: `${thumbnailsHeight + 9}px solid transparent`, + // borderTop: "9px solid transparent", + borderLeft: 0, + borderRight: 0, + borderTop: 0, + borderBottom: `${thumbnailsHeight + 6}px solid transparent`, borderColor: "transparent", // borderRight: "54px solid transparent", // borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead // Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused // boxShadow: "0px 0px 0px 1px var(--vscode-input-border)", - padding: "0 49px 0 9px", + padding: "9px 49px 3px 9px", cursor: textAreaDisabled ? "not-allowed" : undefined, flex: 1, + zIndex: 1, }} + onScroll={() => updateHighlights()} /> {selectedImages.length > 0 && ( ( bottom: 14, left: 22, right: 67, // (54 + 9) + 4 extra padding + zIndex: 2, }} /> )} @@ -184,6 +439,7 @@ const ChatTextArea = forwardRef( alignItems: "flex-center", height: textAreaBaseHeight || 31, bottom: 9, // should be 10 but doesnt look good on mac + zIndex: 2, }}>
void + searchQuery: string + onMouseDown: () => void + selectedIndex: number + setSelectedIndex: (index: number) => void + selectedType: string | null +} + +const ContextMenu: React.FC = ({ + containerWidth, + onSelect, + searchQuery, + onMouseDown, + selectedIndex, + setSelectedIndex, + selectedType, +}) => { + const [filteredOptions, setFilteredOptions] = useState(getContextMenuOptions(searchQuery, selectedType)) + + useEffect(() => { + setFilteredOptions(getContextMenuOptions(searchQuery, selectedType)) + }, [searchQuery, selectedType]) + + return ( +
+
+ {filteredOptions.map((option, index) => ( +
option.type !== "url" && onSelect(option.type, option.value)} + style={{ + padding: "8px 12px", + cursor: option.type !== "url" ? "pointer" : "default", + color: "var(--vscode-dropdown-foreground)", + borderBottom: "1px solid var(--vscode-dropdown-border)", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + backgroundColor: + index === selectedIndex && option.type !== "url" + ? "var(--vscode-list-activeSelectionBackground)" + : "", + // opacity: option.type === "url" ? 0.5 : 1, // Make URL option appear disabled + }} + onMouseEnter={() => option.type !== "url" && setSelectedIndex(index)}> +
+ + {option.value === "File" + ? "Add file" + : option.value === "Folder" + ? "Add folder" + : option.value === "URL" + ? "Paste URL to scrape" + : option.value} +
+ {(option.value === "File" || option.value === "Folder") && ( + + )} + {(option.type === "file" || option.type === "folder") && + option.value !== "File" && + option.value !== "Folder" && ( + + )} +
+ ))} +
+
+ ) +} + +export default ContextMenu diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index a3ed477..8ef8646 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -142,3 +142,15 @@ vscode-dropdown::part(listbox) { .input-icon-button.disabled:hover { opacity: 0.4; } + +.mention-context-highlight { + background-color: var(--vscode-textLink-activeForeground, #3794ff); + opacity: 0.3; + border-radius: 2px; + box-shadow: 0 0 0 0.5px var(--vscode-textLink-activeForeground, #3794ff); + color: transparent; + padding: 0.5px; + margin: -0.5px; + position: relative; + bottom: -0.5px; +} diff --git a/webview-ui/src/utils/mention-context.ts b/webview-ui/src/utils/mention-context.ts new file mode 100644 index 0000000..e10cdd3 --- /dev/null +++ b/webview-ui/src/utils/mention-context.ts @@ -0,0 +1,122 @@ +export const mockPaths = [ + { type: "file", path: "/src/components/Header.tsx" }, + { type: "file", path: "/src/components/Footer.tsx" }, + { type: "file", path: "/src/utils/helpers.ts" }, + { type: "folder", path: "/src/components" }, + { type: "folder", path: "/src/utils" }, + { type: "folder", path: "/public/images" }, + { type: "file", path: "/public/index.html" }, + { type: "file", path: "/package.json" }, + { type: "folder", path: "/node_modules" }, + { type: "file", path: "/README.md" }, +] + +export function insertMention(text: string, position: number, value: string): string { + const beforeCursor = text.slice(0, position) + const afterCursor = text.slice(position) + + // Find the position of the last '@' symbol before the cursor + const lastAtIndex = beforeCursor.lastIndexOf("@") + + if (lastAtIndex !== -1) { + // If there's an '@' symbol, replace everything after it with the new mention + const beforeMention = text.slice(0, lastAtIndex) + return beforeMention + "@" + value + " " + afterCursor.replace(/^[^\s]*/, "") + } else { + // If there's no '@' symbol, insert the mention at the cursor position + return beforeCursor + "@" + value + " " + afterCursor + } +} + +export function removeMention(text: string, position: number): { newText: string; newPosition: number } { + const mentionRegex = /@(\/|\w+:\/\/)[^\s]+/ + const beforeCursor = text.slice(0, position) + const afterCursor = text.slice(position) + + // Check if we're at the end of a mention + const matchEnd = beforeCursor.match(new RegExp(mentionRegex.source + "$")) + + if (matchEnd) { + // If we're at the end of a mention, remove it + const newText = text.slice(0, position - matchEnd[0].length) + afterCursor.replace(" ", "") // removes the first space after the mention + const newPosition = position - matchEnd[0].length + return { newText, newPosition } + } + + // If we're not at the end of a mention, just return the original text and position + return { newText: text, newPosition: position } +} + +export function searchPaths(query: string): { type: string; path: string }[] { + const lowerQuery = query.toLowerCase() + return mockPaths.filter( + (item) => item.path.toLowerCase().includes(lowerQuery) || item.type.toLowerCase().includes(lowerQuery) + ) +} + +export function getContextMenuOptions( + query: string, + selectedType: string | null = null +): { type: string; value: string; icon: string }[] { + if (selectedType === "file") { + return mockPaths + .filter((item) => item.type === "file") + .map((item) => ({ type: "file", value: item.path, icon: "file" })) + } + + if (selectedType === "folder") { + return mockPaths + .filter((item) => item.type === "folder") + .map((item) => ({ type: "folder", value: item.path, icon: "folder" })) + } + + if (query === "") { + return [ + { type: "url", value: "URL", icon: "link" }, + { type: "folder", value: "Folder", icon: "folder" }, + { type: "file", value: "File", icon: "file" }, + ] + } + + const lowerQuery = query.toLowerCase() + + if (query.startsWith("http")) { + // URLs + return [{ type: "url", value: query, icon: "link" }] + } else { + // Search for files and folders + const matchingPaths = mockPaths.filter( + (item) => item.path.toLowerCase().includes(lowerQuery) || item.type.toLowerCase().includes(lowerQuery) + ) + + if (matchingPaths.length > 0) { + return matchingPaths.map((item) => ({ + type: item.type, + value: item.path, + icon: item.type === "file" ? "file" : "folder", + })) + } else { + // If no matches, show all options + return [ + { type: "url", value: "URL", icon: "link" }, + { type: "folder", value: "Folder", icon: "folder" }, + { type: "file", value: "File", icon: "file" }, + ] + } + } +} + +export function shouldShowContextMenu(text: string, position: number): boolean { + const beforeCursor = text.slice(0, position) + const atIndex = beforeCursor.lastIndexOf("@") + + if (atIndex === -1) return false + + const textAfterAt = beforeCursor.slice(atIndex + 1) + + // Check if there's any whitespace after the '@' + if (/\s/.test(textAfterAt)) return false + + // Show the menu if there's just '@' or '@' followed by some text + return true +}