Add a Git section to the context mentions

This commit is contained in:
Matt Rubens
2025-01-06 01:50:06 -05:00
parent 6e834d2fc3
commit 7e9ea7ac28
17 changed files with 987 additions and 207 deletions

View File

@@ -12,8 +12,8 @@ import {
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import Thumbnails from "../common/Thumbnails"
import { vscode } from "../../utils/vscode"
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
interface ChatTextAreaProps {
inputValue: string
@@ -46,6 +46,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
) => {
const { filePaths, apiConfiguration } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [gitCommits, setGitCommits] = useState<any[]>([])
// Handle enhanced prompt response
useEffect(() => {
@@ -54,6 +55,15 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (message.type === 'enhancedPrompt' && message.text) {
setInputValue(message.text)
setIsEnhancingPrompt(false)
} else if (message.type === 'commitSearchResults') {
const commits = message.commits.map((commit: any) => ({
type: ContextMenuOptionType.Git,
value: commit.hash,
label: commit.subject,
description: `${commit.shortHash} by ${commit.author} on ${commit.date}`,
icon: "$(git-commit)"
}))
setGitCommits(commits)
}
}
window.addEventListener('message', messageHandler)
@@ -73,29 +83,40 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
// Fetch git commits when Git is selected or when typing a hash
useEffect(() => {
if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) {
const message: WebviewMessage = {
type: "searchCommits",
query: searchQuery || ""
} as const
vscode.postMessage(message)
}
}, [selectedType, searchQuery])
const handleEnhancePrompt = useCallback(() => {
if (!textAreaDisabled) {
const trimmedInput = inputValue.trim()
if (trimmedInput) {
setIsEnhancingPrompt(true)
const message = {
type: "enhancePrompt" as const,
text: trimmedInput,
}
vscode.postMessage(message)
} else {
const promptDescription = "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works."
setInputValue(promptDescription)
}
}
if (!textAreaDisabled) {
const trimmedInput = inputValue.trim()
if (trimmedInput) {
setIsEnhancingPrompt(true)
const message = {
type: "enhancePrompt" as const,
text: trimmedInput,
}
vscode.postMessage(message)
} else {
const promptDescription = "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works."
setInputValue(promptDescription)
}
}
}, [inputValue, textAreaDisabled, setInputValue])
const queryItems = useMemo(() => {
return [
{ type: ContextMenuOptionType.Problems, value: "problems" },
...gitCommits,
...filePaths
.map((file) => "/" + file)
.map((path) => ({
@@ -103,7 +124,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
value: path,
})),
]
}, [filePaths])
}, [filePaths, gitCommits])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -130,7 +151,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return
}
if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
if (type === ContextMenuOptionType.File ||
type === ContextMenuOptionType.Folder ||
type === ContextMenuOptionType.Git) {
if (!value) {
setSelectedType(type)
setSearchQuery("")
@@ -149,6 +172,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
insertValue = value || ""
} else if (type === ContextMenuOptionType.Problems) {
insertValue = "problems"
} else if (type === ContextMenuOptionType.Git) {
insertValue = value || ""
}
const { newValue, mentionIndex } = insertMention(
@@ -161,7 +186,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition)
// textAreaRef.current.focus()
// scroll to cursor
setTimeout(() => {
@@ -179,7 +203,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showContextMenu) {
if (event.key === "Escape") {
// event.preventDefault()
setSelectedType(null)
setSelectedMenuIndex(3) // File by default
return
@@ -356,19 +379,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setShowContextMenu(false)
// Scroll to new cursor position
// https://stackoverflow.com/questions/29899364/how-do-you-scroll-to-the-position-of-the-cursor-in-a-textarea/40951875#40951875
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.blur()
textAreaRef.current.focus()
}
}, 0)
// NOTE: callbacks dont utilize return function to cleanup, but it's fine since this timeout immediately executes and will be cleaned up by the browser (no chance component unmounts before it executes)
return
}
const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg)
const acceptedTypes = ["png", "jpeg", "webp"]
const imageItems = Array.from(items).filter((item) => {
const [type, subtype] = item.type.split("/")
return type === "image" && acceptedTypes.includes(subtype)
@@ -397,7 +418,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
})
const imageDataArray = await Promise.all(imagePromises)
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
//.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it
if (dataUrls.length > 0) {
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
} else {
@@ -602,7 +622,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
boxSizing: "border-box",
backgroundColor: "transparent",
color: "var(--vscode-input-foreground)",
//border: "1px solid var(--vscode-input-border)",
borderRadius: 2,
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
@@ -610,18 +629,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
resize: "none",
overflowX: "hidden",
overflowY: "scroll",
// 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",
borderLeft: 0,
borderRight: 0,
borderTop: 0,
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
borderColor: "transparent",
padding: "9px 9px 25px 9px",
// 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)",
cursor: textAreaDisabled ? "not-allowed" : undefined,
flex: 1,
zIndex: 1,
@@ -645,21 +658,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
)}
<div className="button-row" style={{ position: "absolute", right: 20, display: "flex", alignItems: "center", height: 31, bottom: 8, zIndex: 2, justifyContent: "flex-end" }}>
<span style={{ display: "flex", alignItems: "center", gap: 12 }}>
{apiConfiguration?.apiProvider === "openrouter" && (
<div style={{ display: "flex", alignItems: "center" }}>
{isEnhancingPrompt && <span style={{ marginRight: 10, color: "var(--vscode-input-foreground)", opacity: 0.5 }}>Enhancing prompt...</span>}
<span
role="button"
aria-label="enhance prompt"
data-testid="enhance-prompt-button"
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-sparkle`}
onClick={() => !textAreaDisabled && handleEnhancePrompt()}
style={{ fontSize: 16.5 }}
/>
</div>
)}
<span className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`} onClick={() => !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} />
<span className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`} onClick={() => !textAreaDisabled && onSend()} style={{ fontSize: 15 }} />
{apiConfiguration?.apiProvider === "openrouter" && (
<div style={{ display: "flex", alignItems: "center" }}>
{isEnhancingPrompt && <span style={{ marginRight: 10, color: "var(--vscode-input-foreground)", opacity: 0.5 }}>Enhancing prompt...</span>}
<span
role="button"
aria-label="enhance prompt"
data-testid="enhance-prompt-button"
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-sparkle`}
onClick={() => !textAreaDisabled && handleEnhancePrompt()}
style={{ fontSize: 16.5 }}
/>
</div>
)}
<span className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`} onClick={() => !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} />
<span className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`} onClick={() => !textAreaDisabled && onSend()} style={{ fontSize: 15 }} />
</span>
</div>
</div>

View File

@@ -52,6 +52,26 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
return <span>Paste URL to fetch contents</span>
case ContextMenuOptionType.NoResults:
return <span>No results found</span>
case ContextMenuOptionType.Git:
if (option.value) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<span style={{ lineHeight: '1.2' }}>{option.label}</span>
<span style={{
fontSize: '0.85em',
opacity: 0.7,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '1.2'
}}>
{option.description}
</span>
</div>
)
} else {
return <span>Git Commits</span>
}
case ContextMenuOptionType.File:
case ContextMenuOptionType.Folder:
if (option.value) {
@@ -87,6 +107,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
return "warning"
case ContextMenuOptionType.URL:
return "link"
case ContextMenuOptionType.Git:
return "git-commit"
case ContextMenuOptionType.NoResults:
return "info"
default:
@@ -121,7 +143,6 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
maxHeight: "200px",
overflowY: "auto",
}}>
{/* Can't use virtuoso since it requires fixed height and menu height is dynamic based on # of items */}
{filteredOptions.map((option, index) => (
<div
key={`${option.type}-${option.value || index}`}
@@ -147,24 +168,33 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
flex: 1,
minWidth: 0,
overflow: "hidden",
paddingTop: 0
}}>
<i
className={`codicon codicon-${getIconForOption(option)}`}
style={{ marginRight: "8px", flexShrink: 0, fontSize: "14px" }}
style={{
marginRight: "6px",
flexShrink: 0,
fontSize: "14px",
marginTop: 0
}}
/>
{renderOptionContent(option)}
</div>
{(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) &&
!option.value && (
<i
className="codicon codicon-chevron-right"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
/>
)}
{((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
!option.value) && (
<i
className="codicon codicon-chevron-right"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
/>
)}
{(option.type === ContextMenuOptionType.Problems ||
((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder) &&
option.value)) && (
((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
option.value)) && (
<i
className="codicon codicon-add"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}

View File

@@ -0,0 +1,46 @@
import { vscode } from "../utils/vscode"
export interface GitCommit {
hash: string
shortHash: string
subject: string
author: string
date: string
}
class GitService {
private commits: GitCommit[] | null = null
private lastQuery: string = ''
async searchCommits(query: string = ''): Promise<GitCommit[]> {
if (query === this.lastQuery && this.commits) {
return this.commits
}
// Request search from extension
vscode.postMessage({ type: 'searchCommits', query })
// Wait for response
const response = await new Promise<GitCommit[]>((resolve) => {
const handler = (event: MessageEvent) => {
const message = event.data
if (message.type === 'commitSearchResults') {
window.removeEventListener('message', handler)
resolve(message.commits)
}
}
window.addEventListener('message', handler)
})
this.commits = response
this.lastQuery = query
return response
}
clearCache() {
this.commits = null
this.lastQuery = ''
}
}
export const gitService = new GitService()

View File

@@ -0,0 +1,130 @@
import { insertMention, removeMention, getContextMenuOptions, shouldShowContextMenu, ContextMenuOptionType, ContextMenuQueryItem } from '../context-mentions'
describe('insertMention', () => {
it('should insert mention at cursor position when no @ symbol exists', () => {
const result = insertMention('Hello world', 5, 'test')
expect(result.newValue).toBe('Hello@test world')
expect(result.mentionIndex).toBe(5)
})
it('should replace text after last @ symbol', () => {
const result = insertMention('Hello @wor world', 8, 'test')
expect(result.newValue).toBe('Hello @test world')
expect(result.mentionIndex).toBe(6)
})
it('should handle empty text', () => {
const result = insertMention('', 0, 'test')
expect(result.newValue).toBe('@test ')
expect(result.mentionIndex).toBe(0)
})
})
describe('removeMention', () => {
it('should remove mention when cursor is at end of mention', () => {
// Test with the problems keyword that matches the regex
const result = removeMention('Hello @problems ', 15)
expect(result.newText).toBe('Hello ')
expect(result.newPosition).toBe(6)
})
it('should not remove text when not at end of mention', () => {
const result = removeMention('Hello @test world', 8)
expect(result.newText).toBe('Hello @test world')
expect(result.newPosition).toBe(8)
})
it('should handle text without mentions', () => {
const result = removeMention('Hello world', 5)
expect(result.newText).toBe('Hello world')
expect(result.newPosition).toBe(5)
})
})
describe('getContextMenuOptions', () => {
const mockQueryItems: ContextMenuQueryItem[] = [
{
type: ContextMenuOptionType.File,
value: 'src/test.ts',
label: 'test.ts',
description: 'Source file'
},
{
type: ContextMenuOptionType.Git,
value: 'abc1234',
label: 'Initial commit',
description: 'First commit',
icon: '$(git-commit)'
},
{
type: ContextMenuOptionType.Folder,
value: 'src',
label: 'src',
description: 'Source folder'
}
]
it('should return all option types for empty query', () => {
const result = getContextMenuOptions('', null, [])
expect(result).toHaveLength(5)
expect(result.map(item => item.type)).toEqual([
ContextMenuOptionType.Problems,
ContextMenuOptionType.URL,
ContextMenuOptionType.Folder,
ContextMenuOptionType.File,
ContextMenuOptionType.Git
])
})
it('should filter by selected type when query is empty', () => {
const result = getContextMenuOptions('', ContextMenuOptionType.File, mockQueryItems)
expect(result).toHaveLength(1)
expect(result[0].type).toBe(ContextMenuOptionType.File)
expect(result[0].value).toBe('src/test.ts')
})
it('should match git commands', () => {
const result = getContextMenuOptions('git', null, mockQueryItems)
expect(result[0].type).toBe(ContextMenuOptionType.Git)
expect(result[0].label).toBe('Git Commits')
})
it('should match git commit hashes', () => {
const result = getContextMenuOptions('abc1234', null, mockQueryItems)
expect(result[0].type).toBe(ContextMenuOptionType.Git)
expect(result[0].value).toBe('abc1234')
})
it('should return NoResults when no matches found', () => {
const result = getContextMenuOptions('nonexistent', null, mockQueryItems)
expect(result).toHaveLength(1)
expect(result[0].type).toBe(ContextMenuOptionType.NoResults)
})
})
describe('shouldShowContextMenu', () => {
it('should return true for @ symbol', () => {
expect(shouldShowContextMenu('@', 1)).toBe(true)
})
it('should return true for @ followed by text', () => {
expect(shouldShowContextMenu('Hello @test', 10)).toBe(true)
})
it('should return false when no @ symbol exists', () => {
expect(shouldShowContextMenu('Hello world', 5)).toBe(false)
})
it('should return false for @ followed by whitespace', () => {
expect(shouldShowContextMenu('Hello @ world', 6)).toBe(false)
})
it('should return false for @ in URL', () => {
expect(shouldShowContextMenu('Hello @http://test.com', 17)).toBe(false)
})
it('should return false for @problems', () => {
// Position cursor at the end to test the full word
expect(shouldShowContextMenu('@problems', 9)).toBe(false)
})
})

View File

@@ -51,12 +51,16 @@ export enum ContextMenuOptionType {
Folder = "folder",
Problems = "problems",
URL = "url",
Git = "git",
NoResults = "noResults",
}
export interface ContextMenuQueryItem {
type: ContextMenuOptionType
value?: string
label?: string
description?: string
icon?: string
}
export function getContextMenuOptions(
@@ -64,6 +68,14 @@ export function getContextMenuOptions(
selectedType: ContextMenuOptionType | null = null,
queryItems: ContextMenuQueryItem[],
): ContextMenuQueryItem[] {
const workingChanges: ContextMenuQueryItem = {
type: ContextMenuOptionType.Git,
value: "git-changes",
label: "Working changes",
description: "Current uncommitted changes",
icon: "$(git-commit)"
}
if (query === "") {
if (selectedType === ContextMenuOptionType.File) {
const files = queryItems
@@ -79,30 +91,88 @@ export function getContextMenuOptions(
return folders.length > 0 ? folders : [{ type: ContextMenuOptionType.NoResults }]
}
if (selectedType === ContextMenuOptionType.Git) {
const commits = queryItems
.filter((item) => item.type === ContextMenuOptionType.Git)
return commits.length > 0 ? [workingChanges, ...commits] : [workingChanges]
}
return [
{ type: ContextMenuOptionType.URL },
{ type: ContextMenuOptionType.Problems },
{ type: ContextMenuOptionType.URL },
{ type: ContextMenuOptionType.Folder },
{ type: ContextMenuOptionType.File },
{ type: ContextMenuOptionType.Git },
]
}
const lowerQuery = query.toLowerCase()
const suggestions: ContextMenuQueryItem[] = []
// Check for top-level option matches
if ("git".startsWith(lowerQuery)) {
suggestions.push({
type: ContextMenuOptionType.Git,
label: "Git Commits",
description: "Search repository history",
icon: "$(git-commit)"
})
} else if ("git-changes".startsWith(lowerQuery)) {
suggestions.push(workingChanges)
}
if ("problems".startsWith(lowerQuery)) {
suggestions.push({ type: ContextMenuOptionType.Problems })
}
if (query.startsWith("http")) {
return [{ type: ContextMenuOptionType.URL, value: query }]
} else {
const matchingItems = queryItems.filter((item) => item.value?.toLowerCase().includes(lowerQuery))
suggestions.push({ type: ContextMenuOptionType.URL, value: query })
}
if (matchingItems.length > 0) {
return matchingItems.map((item) => ({
type: item.type,
value: item.value,
}))
// Add exact SHA matches to suggestions
if (/^[a-f0-9]{7,40}$/i.test(lowerQuery)) {
const exactMatches = queryItems.filter((item) =>
item.type === ContextMenuOptionType.Git &&
item.value?.toLowerCase() === lowerQuery
)
if (exactMatches.length > 0) {
suggestions.push(...exactMatches)
} else {
return [{ type: ContextMenuOptionType.NoResults }]
// If no exact match but valid SHA format, add as option
suggestions.push({
type: ContextMenuOptionType.Git,
value: lowerQuery,
label: `Commit ${lowerQuery}`,
description: "Git commit hash",
icon: "$(git-commit)"
})
}
}
// Get matching items, separating by type
const matchingItems = queryItems.filter((item) =>
item.value?.toLowerCase().includes(lowerQuery) ||
item.label?.toLowerCase().includes(lowerQuery) ||
item.description?.toLowerCase().includes(lowerQuery)
)
const fileMatches = matchingItems.filter(item =>
item.type === ContextMenuOptionType.File ||
item.type === ContextMenuOptionType.Folder
)
const gitMatches = matchingItems.filter(item =>
item.type === ContextMenuOptionType.Git
)
const otherMatches = matchingItems.filter(item =>
item.type !== ContextMenuOptionType.File &&
item.type !== ContextMenuOptionType.Folder &&
item.type !== ContextMenuOptionType.Git
)
// Combine suggestions with matching items in the desired order
if (suggestions.length > 0 || matchingItems.length > 0) {
return [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
}
return [{ type: ContextMenuOptionType.NoResults }]
}
export function shouldShowContextMenu(text: string, position: number): boolean {