Refactor to use ContextMenuOptionType and ContextMenuQueryItem

This commit is contained in:
Saoud Rizwan
2024-09-18 12:13:08 -04:00
parent f104754d3e
commit 22bf10420e
3 changed files with 147 additions and 142 deletions

View File

@@ -1,4 +1,4 @@
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import DynamicTextArea from "react-textarea-autosize" import DynamicTextArea from "react-textarea-autosize"
import { useExtensionState } from "../context/ExtensionStateContext" import { useExtensionState } from "../context/ExtensionStateContext"
import { import {
@@ -8,6 +8,7 @@ import {
mentionRegexGlobal, mentionRegexGlobal,
removeMention, removeMention,
shouldShowContextMenu, shouldShowContextMenu,
ContextMenuOptionType,
} from "../utils/mention-context" } from "../utils/mention-context"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu" import ContextMenu from "./ContextMenu"
@@ -42,6 +43,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}, },
ref ref
) => { ) => {
const { filePaths } = useExtensionState()
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)
@@ -52,21 +54,19 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false) const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false)
const highlightLayerRef = useRef<HTMLDivElement>(null) const highlightLayerRef = useRef<HTMLDivElement>(null)
const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1) const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1)
const [selectedType, setSelectedType] = useState<string | null>(null) const [selectedType, setSelectedType] = useState<ContextMenuOptionType | null>(null)
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false) const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null) const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
const contextMenuContainerRef = useRef<HTMLDivElement>(null) const contextMenuContainerRef = useRef<HTMLDivElement>(null)
const { filePaths } = useExtensionState() const queryItems = useMemo(() => {
const searchPaths = React.useMemo(() => {
return [ return [
{ type: "problems", path: "problems" }, { type: ContextMenuOptionType.Problems, value: "problems" },
...filePaths ...filePaths
.map((file) => "/" + file) .map((file) => "/" + file)
.map((path) => ({ .map((path) => ({
type: path.endsWith("/") ? "folder" : "file", type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
path: path, value: path,
})), })),
] ]
}, [filePaths]) }, [filePaths])
@@ -91,30 +91,29 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}, [showContextMenu, setShowContextMenu]) }, [showContextMenu, setShowContextMenu])
const handleMentionSelect = useCallback( const handleMentionSelect = useCallback(
(type: string, value: string) => { (type: ContextMenuOptionType, value?: string) => {
if (type === "noResults") { if (type === ContextMenuOptionType.NoResults) {
return return
} }
if (value === "file" || value === "folder") { if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
setSelectedType(type.toLowerCase()) if (!value) {
setSearchQuery("") setSelectedType(type)
setSelectedMenuIndex(0) setSearchQuery("")
return setSelectedMenuIndex(0)
return
}
} }
setShowContextMenu(false) setShowContextMenu(false)
setSelectedType(null) setSelectedType(null)
if (textAreaRef.current) { if (textAreaRef.current) {
let insertValue = value let insertValue = value || ""
if (type === "url") { if (type === ContextMenuOptionType.URL) {
// For URLs, we insert the value as is insertValue = value || ""
insertValue = value } else if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
} else if (type === "file" || type === "folder") { insertValue = value || ""
// For files and folders, we insert the path } else if (type === ContextMenuOptionType.Problems) {
insertValue = value
} else if (type === "problems") {
// For workspace problems, we insert @problems
insertValue = "problems" insertValue = "problems"
} }
@@ -122,10 +121,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setInputValue(newValue) setInputValue(newValue)
const newCursorPosition = newValue.indexOf(" ", newValue.lastIndexOf("@")) + 1 const newCursorPosition = newValue.indexOf(" ", newValue.lastIndexOf("@")) + 1
setCursorPosition(newCursorPosition) setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition) // Update intended cursor position setIntendedCursorPosition(newCursorPosition)
textAreaRef.current.focus() textAreaRef.current.focus()
// Remove the direct setSelectionRange call
// textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition)
} }
}, },
[setInputValue, cursorPosition] [setInputValue, cursorPosition]
@@ -145,14 +142,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
event.preventDefault() event.preventDefault()
setSelectedMenuIndex((prevIndex) => { setSelectedMenuIndex((prevIndex) => {
const direction = event.key === "ArrowUp" ? -1 : 1 const direction = event.key === "ArrowUp" ? -1 : 1
const options = getContextMenuOptions(searchQuery, selectedType, searchPaths) const options = getContextMenuOptions(searchQuery, selectedType, queryItems)
const optionsLength = options.length const optionsLength = options.length
if (optionsLength === 0) return prevIndex if (optionsLength === 0) return prevIndex
// Find selectable options (non-URL types) // Find selectable options (non-URL types)
const selectableOptions = options.filter( const selectableOptions = options.filter(
(option) => option.type !== "url" && option.type !== "noResults" (option) =>
option.type !== ContextMenuOptionType.URL &&
option.type !== ContextMenuOptionType.NoResults
) )
if (selectableOptions.length === 0) return -1 // No selectable options if (selectableOptions.length === 0) return -1 // No selectable options
@@ -173,10 +172,14 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
} }
if (event.key === "Enter" && selectedMenuIndex !== -1) { if (event.key === "Enter" && selectedMenuIndex !== -1) {
event.preventDefault() event.preventDefault()
const selectedOption = getContextMenuOptions(searchQuery, selectedType, searchPaths)[ const selectedOption = getContextMenuOptions(searchQuery, selectedType, queryItems)[
selectedMenuIndex selectedMenuIndex
] ]
if (selectedOption && selectedOption.type !== "url" && selectedOption.type !== "noResults") { if (
selectedOption &&
selectedOption.type !== ContextMenuOptionType.URL &&
selectedOption.type !== ContextMenuOptionType.NoResults
) {
handleMentionSelect(selectedOption.type, selectedOption.value) handleMentionSelect(selectedOption.type, selectedOption.value)
} }
return return
@@ -236,7 +239,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
cursorPosition, cursorPosition,
setInputValue, setInputValue,
justDeletedSpaceAfterMention, justDeletedSpaceAfterMention,
searchPaths, queryItems,
] ]
) )
@@ -411,7 +414,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
selectedIndex={selectedMenuIndex} selectedIndex={selectedMenuIndex}
setSelectedIndex={setSelectedMenuIndex} setSelectedIndex={setSelectedMenuIndex}
selectedType={selectedType} selectedType={selectedType}
searchPaths={searchPaths} queryItems={queryItems}
/> />
</div> </div>
)} )}

View File

@@ -1,15 +1,15 @@
import React, { useEffect, useRef, useState } from "react" import React, { useEffect, useRef, useState } from "react"
import { getContextMenuOptions } from "../utils/mention-context" import { getContextMenuOptions, ContextMenuOptionType, ContextMenuQueryItem } from "../utils/mention-context"
import { formatFilePathForTruncation } from "./CodeAccordian" import { formatFilePathForTruncation } from "./CodeAccordian"
interface ContextMenuProps { interface ContextMenuProps {
onSelect: (type: string, value: string) => void onSelect: (type: ContextMenuOptionType, value?: string) => void
searchQuery: string searchQuery: string
onMouseDown: () => void onMouseDown: () => void
selectedIndex: number selectedIndex: number
setSelectedIndex: (index: number) => void setSelectedIndex: (index: number) => void
selectedType: string | null selectedType: ContextMenuOptionType | null
searchPaths: { type: string; path: string }[] queryItems: ContextMenuQueryItem[]
} }
const ContextMenu: React.FC<ContextMenuProps> = ({ const ContextMenu: React.FC<ContextMenuProps> = ({
@@ -19,16 +19,16 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
selectedIndex, selectedIndex,
setSelectedIndex, setSelectedIndex,
selectedType, selectedType,
searchPaths, queryItems,
}) => { }) => {
const [filteredOptions, setFilteredOptions] = useState( const [filteredOptions, setFilteredOptions] = useState<ContextMenuQueryItem[]>(
getContextMenuOptions(searchQuery, selectedType, searchPaths) getContextMenuOptions(searchQuery, selectedType, queryItems)
) )
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
setFilteredOptions(getContextMenuOptions(searchQuery, selectedType, searchPaths)) setFilteredOptions(getContextMenuOptions(searchQuery, selectedType, queryItems))
}, [searchQuery, selectedType, searchPaths]) }, [searchQuery, selectedType, queryItems])
useEffect(() => { useEffect(() => {
if (menuRef.current) { if (menuRef.current) {
@@ -46,48 +46,57 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
} }
}, [selectedIndex]) }, [selectedIndex])
const renderOptionContent = (option: { type: string; value: string }) => { const renderOptionContent = (option: ContextMenuQueryItem) => {
switch (option.value) { switch (option.type) {
case "file": case ContextMenuOptionType.Problems:
case "folder": return <span>Problems</span>
case "problems": case ContextMenuOptionType.URL:
case "url": return <span>Paste URL to scrape</span>
case "noResults": case ContextMenuOptionType.NoResults:
return ( return <span>No results found</span>
<span case ContextMenuOptionType.File:
style={{ case ContextMenuOptionType.Folder:
whiteSpace: "nowrap", if (option.value) {
overflow: "hidden", return (
textOverflow: "ellipsis", <span
}}> style={{
{option.value === "file" whiteSpace: "nowrap",
? "Add File" overflow: "hidden",
: option.value === "folder" textOverflow: "ellipsis",
? "Add Folder" direction: "rtl",
: option.value === "problems" textAlign: "left",
? "Problems" unicodeBidi: "plaintext",
: option.value === "url" }}>
? "Paste URL to scrape" {formatFilePathForTruncation(option.value || "") + "\u200E"}
: "No results found"} </span>
</span> )
) } else {
default: return <span>Add {option.type === ContextMenuOptionType.File ? "File" : "Folder"}</span>
return ( }
<span
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
direction: "rtl",
textAlign: "left",
unicodeBidi: "plaintext",
}}>
{formatFilePathForTruncation(option.value) + "\u200E"}
</span>
)
} }
} }
const getIconForOption = (option: ContextMenuQueryItem): string => {
switch (option.type) {
case ContextMenuOptionType.File:
return "file"
case ContextMenuOptionType.Folder:
return "folder"
case ContextMenuOptionType.Problems:
return "warning"
case ContextMenuOptionType.URL:
return "link"
case ContextMenuOptionType.NoResults:
return "info"
default:
return "file"
}
}
const isOptionSelectable = (option: ContextMenuQueryItem): boolean => {
return option.type !== ContextMenuOptionType.NoResults && option.type !== ContextMenuOptionType.URL
}
return ( return (
<div <div
style={{ style={{
@@ -113,50 +122,47 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
}}> }}>
{filteredOptions.map((option, index) => ( {filteredOptions.map((option, index) => (
<div <div
key={option.value} key={`${option.type}-${option.value || index}`}
onClick={() => onClick={() => isOptionSelectable(option) && onSelect(option.type, option.value)}
option.type !== "url" && option.type !== "noResults" && onSelect(option.type, option.value)
}
style={{ style={{
padding: "8px 12px", padding: "8px 12px",
cursor: option.type !== "url" && option.type !== "noResults" ? "pointer" : "default", cursor: isOptionSelectable(option) ? "pointer" : "default",
color: "var(--vscode-dropdown-foreground)", color: "var(--vscode-dropdown-foreground)",
borderBottom: "1px solid var(--vscode-dropdown-border)", borderBottom: "1px solid var(--vscode-dropdown-border)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
backgroundColor: backgroundColor:
index === selectedIndex && option.type !== "url" && option.type !== "noResults" index === selectedIndex && isOptionSelectable(option)
? "var(--vscode-list-activeSelectionBackground)" ? "var(--vscode-list-activeSelectionBackground)"
: "", : "",
}} }}
onMouseEnter={() => onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}>
option.type !== "url" && option.type !== "noResults" && setSelectedIndex(index)
}>
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
flex: 1, flex: 1,
minWidth: 0, // Allows child to shrink below content size minWidth: 0,
overflow: "hidden", // Ensures content doesn't overflow overflow: "hidden",
}}> }}>
<i <i
className={`codicon codicon-${option.icon}`} className={`codicon codicon-${getIconForOption(option)}`}
style={{ marginRight: "8px", flexShrink: 0, fontSize: "14px" }} style={{ marginRight: "8px", flexShrink: 0, fontSize: "14px" }}
/> />
{renderOptionContent(option)} {renderOptionContent(option)}
</div> </div>
{(option.value === "file" || option.value === "folder") && ( {(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) &&
<i !option.value && (
className="codicon codicon-chevron-right" <i
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }} className="codicon codicon-chevron-right"
/> style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
)} />
{(option.type === "problems" || )}
((option.type === "file" || option.type === "folder") && {(option.type === ContextMenuOptionType.Problems ||
option.value !== "file" && ((option.type === ContextMenuOptionType.File ||
option.value !== "folder")) && ( option.type === ContextMenuOptionType.Folder) &&
option.value)) && (
<i <i
className="codicon codicon-add" className="codicon codicon-add"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }} style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}

View File

@@ -43,65 +43,61 @@ export function removeMention(text: string, position: number): { newText: string
return { newText: text, newPosition: position } return { newText: text, newPosition: position }
} }
// export function queryPaths( export enum ContextMenuOptionType {
// query: string, File = "file",
// searchPaths: { type: string; path: string }[] Folder = "folder",
// ): { type: string; path: string }[] { Problems = "problems",
// const lowerQuery = query.toLowerCase() URL = "url",
// return searchPaths.filter( NoResults = "noResults",
// (item) => item.path.toLowerCase().includes(lowerQuery) || item.type.toLowerCase().includes(lowerQuery) }
// )
// } export interface ContextMenuQueryItem {
type: ContextMenuOptionType
value?: string
}
export function getContextMenuOptions( export function getContextMenuOptions(
query: string, query: string,
selectedType: string | null = null, selectedType: ContextMenuOptionType | null = null,
searchPaths: { type: string; path: string }[] queryItems: ContextMenuQueryItem[]
): { type: string; value: string; icon: string }[] { ): ContextMenuQueryItem[] {
if (query === "") { if (query === "") {
if (selectedType === "file") { if (selectedType === ContextMenuOptionType.File) {
const files = searchPaths const files = queryItems
.filter((item) => item.type === "file") .filter((item) => item.type === ContextMenuOptionType.File)
.map((item) => ({ type: "file", value: item.path, icon: "file" })) .map((item) => ({ type: ContextMenuOptionType.File, value: item.value }))
return files.length > 0 ? files : [{ type: "noResults", value: "noResults", icon: "info" }] return files.length > 0 ? files : [{ type: ContextMenuOptionType.NoResults }]
} }
if (selectedType === "folder") { if (selectedType === ContextMenuOptionType.Folder) {
const folders = searchPaths const folders = queryItems
.filter((item) => item.type === "folder") .filter((item) => item.type === ContextMenuOptionType.Folder)
.map((item) => ({ type: "folder", value: item.path, icon: "folder" })) .map((item) => ({ type: ContextMenuOptionType.Folder, value: item.value }))
return folders.length > 0 ? folders : [{ type: "noResults", value: "noResults", icon: "info" }] return folders.length > 0 ? folders : [{ type: ContextMenuOptionType.NoResults }]
} }
return [ return [
{ type: "url", value: "url", icon: "link" }, { type: ContextMenuOptionType.URL },
{ { type: ContextMenuOptionType.Problems },
type: "problems", { type: ContextMenuOptionType.Folder },
value: "problems", { type: ContextMenuOptionType.File },
icon: "warning",
},
{ type: "folder", value: "folder", icon: "folder" },
{ type: "file", value: "file", icon: "file" },
] ]
} }
const lowerQuery = query.toLowerCase() const lowerQuery = query.toLowerCase()
if (query.startsWith("http")) { if (query.startsWith("http")) {
// URLs return [{ type: ContextMenuOptionType.URL, value: query }]
return [{ type: "url", value: query, icon: "link" }]
} else { } else {
// Search for files and folders const matchingItems = queryItems.filter((item) => item.value?.toLowerCase().includes(lowerQuery))
const matchingPaths = searchPaths.filter((item) => item.path.toLowerCase().includes(lowerQuery))
if (matchingPaths.length > 0) { if (matchingItems.length > 0) {
return matchingPaths.map((item) => ({ return matchingItems.map((item) => ({
type: item.type, type: item.type,
value: item.path, value: item.value,
icon: item.type === "file" ? "file" : item.type === "problems" ? "warning" : "folder",
})) }))
} else { } else {
return [{ type: "noResults", value: "noResults", icon: "info" }] return [{ type: ContextMenuOptionType.NoResults }]
} }
} }
} }