mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 12:21:13 -05:00
Get context mentions UI working
This commit is contained in:
@@ -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 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 { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
|
||||||
|
import ContextMenu from "./ContextMenu"
|
||||||
|
import Thumbnails from "./Thumbnails"
|
||||||
|
|
||||||
interface ChatTextAreaProps {
|
interface ChatTextAreaProps {
|
||||||
inputValue: string
|
inputValue: string
|
||||||
@@ -35,18 +37,173 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||||
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
||||||
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
|
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(
|
const handleKeyDown = useCallback(
|
||||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(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
|
const isComposing = event.nativeEvent?.isComposing ?? false
|
||||||
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
onSend()
|
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(
|
const handlePaste = useCallback(
|
||||||
async (e: React.ClipboardEvent) => {
|
async (e: React.ClipboardEvent) => {
|
||||||
const items = e.clipboardData.items
|
const items = e.clipboardData.items
|
||||||
@@ -100,14 +257,64 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
}
|
}
|
||||||
}, [selectedImages])
|
}, [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, '<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 15px",
|
padding: "10px 15px",
|
||||||
opacity: textAreaDisabled ? 0.5 : 1,
|
opacity: textAreaDisabled ? 0.5 : 1,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
}}>
|
}}>
|
||||||
|
{showContextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
containerWidth={containerRef.current?.clientWidth || 0}
|
||||||
|
onSelect={handleMentionSelect}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onMouseDown={handleMenuMouseDown}
|
||||||
|
selectedIndex={selectedMenuIndex}
|
||||||
|
setSelectedIndex={setSelectedMenuIndex}
|
||||||
|
selectedType={selectedType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!isTextAreaFocused && (
|
{!isTextAreaFocused && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -116,18 +323,58 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
border: "1px solid var(--vscode-input-border)",
|
border: "1px solid var(--vscode-input-border)",
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
pointerEvents: "none",
|
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
|
<DynamicTextArea
|
||||||
ref={ref}
|
ref={(el) => {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(el)
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = el
|
||||||
|
}
|
||||||
|
textAreaRef.current = el
|
||||||
|
}}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
disabled={textAreaDisabled}
|
disabled={textAreaDisabled}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
handleInputChange(e)
|
||||||
|
updateHighlights()
|
||||||
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
onFocus={() => setIsTextAreaFocused(true)}
|
onFocus={() => setIsTextAreaFocused(true)}
|
||||||
onBlur={() => setIsTextAreaFocused(false)}
|
onBlur={handleBlur}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
|
onSelect={updateCursorPosition}
|
||||||
|
onMouseUp={updateCursorPosition}
|
||||||
onHeightChange={(height) => {
|
onHeightChange={(height) => {
|
||||||
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
|
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
|
||||||
setTextAreaBaseHeight(height)
|
setTextAreaBaseHeight(height)
|
||||||
@@ -140,7 +387,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
backgroundColor: "var(--vscode-input-background)",
|
backgroundColor: "transparent",
|
||||||
color: "var(--vscode-input-foreground)",
|
color: "var(--vscode-input-foreground)",
|
||||||
//border: "1px solid var(--vscode-input-border)",
|
//border: "1px solid var(--vscode-input-border)",
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
@@ -148,19 +395,26 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
fontSize: "var(--vscode-editor-font-size)",
|
fontSize: "var(--vscode-editor-font-size)",
|
||||||
lineHeight: "var(--vscode-editor-line-height)",
|
lineHeight: "var(--vscode-editor-line-height)",
|
||||||
resize: "none",
|
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)
|
// 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",
|
// borderTop: "9px solid transparent",
|
||||||
borderBottom: `${thumbnailsHeight + 9}px solid transparent`,
|
borderLeft: 0,
|
||||||
|
borderRight: 0,
|
||||||
|
borderTop: 0,
|
||||||
|
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
|
||||||
borderColor: "transparent",
|
borderColor: "transparent",
|
||||||
// borderRight: "54px solid 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
|
// 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
|
// 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)",
|
// boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
|
||||||
padding: "0 49px 0 9px",
|
padding: "9px 49px 3px 9px",
|
||||||
cursor: textAreaDisabled ? "not-allowed" : undefined,
|
cursor: textAreaDisabled ? "not-allowed" : undefined,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
|
onScroll={() => updateHighlights()}
|
||||||
/>
|
/>
|
||||||
{selectedImages.length > 0 && (
|
{selectedImages.length > 0 && (
|
||||||
<Thumbnails
|
<Thumbnails
|
||||||
@@ -173,6 +427,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
bottom: 14,
|
bottom: 14,
|
||||||
left: 22,
|
left: 22,
|
||||||
right: 67, // (54 + 9) + 4 extra padding
|
right: 67, // (54 + 9) + 4 extra padding
|
||||||
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -184,6 +439,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
alignItems: "flex-center",
|
alignItems: "flex-center",
|
||||||
height: textAreaBaseHeight || 31,
|
height: textAreaBaseHeight || 31,
|
||||||
bottom: 9, // should be 10 but doesnt look good on mac
|
bottom: 9, // should be 10 but doesnt look good on mac
|
||||||
|
zIndex: 2,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
|
<div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
|
||||||
<div
|
<div
|
||||||
|
|||||||
94
webview-ui/src/components/ContextMenu.tsx
Normal file
94
webview-ui/src/components/ContextMenu.tsx
Normal file
@@ -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
|
||||||
@@ -142,3 +142,15 @@ vscode-dropdown::part(listbox) {
|
|||||||
.input-icon-button.disabled:hover {
|
.input-icon-button.disabled:hover {
|
||||||
opacity: 0.4;
|
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
webview-ui/src/utils/mention-context.ts
Normal file
122
webview-ui/src/utils/mention-context.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user