Преглед изворни кода

Get context mentions UI working

Saoud Rizwan пре 1 година
родитељ
комит
82490108c5

+ 267 - 11
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<HTMLTextAreaElement, ChatTextAreaProps>(
 		const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
 		const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
 		const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
+		const [showContextMenu, setShowContextMenu] = useState(false)
+		const [cursorPosition, setCursorPosition] = useState(0)
+		const [searchQuery, setSearchQuery] = useState("")
+		const containerRef = useRef<HTMLDivElement>(null)
+		const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
+		const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false)
+		const highlightLayerRef = useRef<HTMLDivElement>(null)
+		const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1)
+		const [selectedType, setSelectedType] = useState<string | null>(null)
+		const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
+		const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(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<HTMLTextAreaElement>) => {
+				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<HTMLTextAreaElement>) => {
+				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<HTMLTextAreaElement, ChatTextAreaProps>(
 			}
 		}, [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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c] || c))
+				.replace(mentionRegex, '<mark class="mention-context-highlight">$&</mark>')
+
+			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<HTMLTextAreaElement>) => {
+				if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
+					updateCursorPosition()
+				}
+			},
+			[updateCursorPosition]
+		)
+
 		return (
 			<div
+				ref={containerRef}
 				style={{
 					padding: "10px 15px",
 					opacity: textAreaDisabled ? 0.5 : 1,
 					position: "relative",
 					display: "flex",
 				}}>
+				{showContextMenu && (
+					<ContextMenu
+						containerWidth={containerRef.current?.clientWidth || 0}
+						onSelect={handleMentionSelect}
+						searchQuery={searchQuery}
+						onMouseDown={handleMenuMouseDown}
+						selectedIndex={selectedMenuIndex}
+						setSelectedIndex={setSelectedMenuIndex}
+						selectedType={selectedType}
+					/>
+				)}
 				{!isTextAreaFocused && (
 					<div
 						style={{
@@ -116,18 +323,58 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 							border: "1px solid var(--vscode-input-border)",
 							borderRadius: 2,
 							pointerEvents: "none",
+							zIndex: 5,
 						}}
 					/>
 				)}
+				<div
+					ref={highlightLayerRef}
+					style={{
+						position: "absolute",
+						top: 10,
+						left: 15,
+						right: 15,
+						bottom: 10,
+						pointerEvents: "none",
+						whiteSpace: "pre-wrap",
+						wordWrap: "break-word",
+						color: "transparent",
+						overflow: "hidden",
+						backgroundColor: "var(--vscode-input-background)",
+						fontFamily: "var(--vscode-font-family)",
+						fontSize: "var(--vscode-editor-font-size)",
+						lineHeight: "var(--vscode-editor-line-height)",
+						borderRadius: 2,
+						borderLeft: 0,
+						borderRight: 0,
+						borderTop: 0,
+						borderColor: "transparent",
+						borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
+						padding: "9px 49px 3px 9px",
+					}}
+				/>
 				<DynamicTextArea
-					ref={ref}
+					ref={(el) => {
+						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<HTMLTextAreaElement, ChatTextAreaProps>(
 					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<HTMLTextAreaElement, ChatTextAreaProps>(
 						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 && (
 					<Thumbnails
@@ -173,6 +427,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 							bottom: 14,
 							left: 22,
 							right: 67, // (54 + 9) + 4 extra padding
+							zIndex: 2,
 						}}
 					/>
 				)}
@@ -184,6 +439,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 						alignItems: "flex-center",
 						height: textAreaBaseHeight || 31,
 						bottom: 9, // should be 10 but doesnt look good on mac
+						zIndex: 2,
 					}}>
 					<div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
 						<div

+ 94 - 0
webview-ui/src/components/ContextMenu.tsx

@@ -0,0 +1,94 @@
+import React, { useEffect, useState } from "react"
+import { getContextMenuOptions } from "../utils/mention-context"
+
+interface ContextMenuProps {
+	containerWidth: number
+	onSelect: (type: string, value: string) => void
+	searchQuery: string
+	onMouseDown: () => void
+	selectedIndex: number
+	setSelectedIndex: (index: number) => void
+	selectedType: string | null
+}
+
+const ContextMenu: React.FC<ContextMenuProps> = ({
+	containerWidth,
+	onSelect,
+	searchQuery,
+	onMouseDown,
+	selectedIndex,
+	setSelectedIndex,
+	selectedType,
+}) => {
+	const [filteredOptions, setFilteredOptions] = useState(getContextMenuOptions(searchQuery, selectedType))
+
+	useEffect(() => {
+		setFilteredOptions(getContextMenuOptions(searchQuery, selectedType))
+	}, [searchQuery, selectedType])
+
+	return (
+		<div
+			style={{
+				position: "absolute",
+				bottom: "calc(100% - 10px)",
+				left: 15,
+				right: 15,
+			}}
+			onMouseDown={onMouseDown}>
+			<div
+				style={{
+					backgroundColor: "var(--vscode-dropdown-background)",
+					border: "1px solid var(--vscode-dropdown-border)",
+					borderRadius: "3px",
+					zIndex: 1000,
+					display: "flex",
+					flexDirection: "column",
+					boxShadow: "0 8px 16px rgba(0,0,0,0.24)",
+					maxHeight: "200px",
+					overflowY: "auto",
+				}}>
+				{filteredOptions.map((option, index) => (
+					<div
+						key={option.value}
+						onClick={() => 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)}>
+						<div style={{ display: "flex", alignItems: "center" }}>
+							<i className={`codicon codicon-${option.icon}`} style={{ marginRight: "8px" }} />
+							{option.value === "File"
+								? "Add file"
+								: option.value === "Folder"
+								? "Add folder"
+								: option.value === "URL"
+								? "Paste URL to scrape"
+								: option.value}
+						</div>
+						{(option.value === "File" || option.value === "Folder") && (
+							<i className="codicon codicon-chevron-right" style={{ fontSize: "14px" }} />
+						)}
+						{(option.type === "file" || option.type === "folder") &&
+							option.value !== "File" &&
+							option.value !== "Folder" && (
+								<i className="codicon codicon-add" style={{ fontSize: "14px" }} />
+							)}
+					</div>
+				))}
+			</div>
+		</div>
+	)
+}
+
+export default ContextMenu

+ 12 - 0
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;
+}

+ 122 - 0
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
+}