From d67c7e36da3d43ed3f0a2da5ae9f68882cdede76 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 14 Feb 2025 19:03:11 -0800 Subject: [PATCH] Add git context mention (#1806) * Add git context mention * Fix context mention highlight * Create long-guests-occur.md --- .changeset/long-guests-occur.md | 5 + src/core/mentions/index.ts | 30 ++- src/core/webview/ClineProvider.ts | 16 ++ src/shared/ExtensionMessage.ts | 3 + src/shared/WebviewMessage.ts | 1 + src/shared/context-mentions.ts | 50 ++--- src/utils/git.ts | 177 ++++++++++++++++++ webview-ui/package-lock.json | 7 + webview-ui/package.json | 1 + .../src/components/chat/ChatTextArea.tsx | 52 ++++- .../src/components/chat/ContextMenu.tsx | 31 ++- webview-ui/src/utils/context-mentions.ts | 100 +++++++++- 12 files changed, 426 insertions(+), 47 deletions(-) create mode 100644 .changeset/long-guests-occur.md create mode 100644 src/utils/git.ts diff --git a/.changeset/long-guests-occur.md b/.changeset/long-guests-occur.md new file mode 100644 index 0000000000..34f6598335 --- /dev/null +++ b/.changeset/long-guests-occur.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +Add git context mention diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index ea9e2afd79..146e823f34 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -8,22 +8,24 @@ import { extractTextFromFile } from "../../integrations/misc/extract-text" import { isBinaryFile } from "isbinaryfile" import { diagnosticsToProblemsString } from "../../integrations/diagnostics" import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output" +import { getCommitInfo } from "../../utils/git" +import { getWorkingState } from "../../utils/git" export function openMention(mention?: string): void { if (!mention) { return } + const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + if (!cwd) { + return + } + if (mention.startsWith("/")) { const relPath = mention.slice(1) - const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) - if (!cwd) { - return - } const absPath = path.resolve(cwd, relPath) if (mention.endsWith("/")) { vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath)) - // vscode.commands.executeCommand("vscode.openFolder", , { forceNewWindow: false }) opens in new window } else { openFile(absPath) } @@ -51,6 +53,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher return `Workspace Problems (see below for diagnostics)` } else if (mention === "terminal") { return `Terminal Output (see below for output)` + } else if (mention === "git-changes") { + return `Working directory changes (see below for details)` + } else if (/^[a-f0-9]{7,40}$/.test(mention)) { + return `Git commit '${mention}' (see below for commit info)` } return match }) @@ -111,6 +117,20 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher } catch (error) { parsedText += `\n\n\nError fetching terminal output: ${error.message}\n` } + } else if (mention === "git-changes") { + try { + const workingState = await getWorkingState(cwd) + parsedText += `\n\n\n${workingState}\n` + } catch (error) { + parsedText += `\n\n\nError fetching working state: ${error.message}\n` + } + } else if (/^[a-f0-9]{7,40}$/.test(mention)) { + try { + const commitInfo = await getCommitInfo(mention, cwd) + parsedText += `\n\n\n${commitInfo}\n` + } catch (error) { + parsedText += `\n\n\nError fetching commit info: ${error.message}\n` + } } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3e59dfad2a..09a735ae1c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -28,6 +28,7 @@ import { getUri } from "./getUri" import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings" import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings" import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings" +import { searchCommits } from "../../utils/git" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -803,6 +804,21 @@ export class ClineProvider implements vscode.WebviewViewProvider { } break } + case "searchCommits": { + const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + if (cwd) { + try { + const commits = await searchCommits(message.text || "", cwd) + await this.postMessageToWebview({ + type: "commitSearchResults", + commits, + }) + } catch (error) { + console.error(`Error searching commits: ${JSON.stringify(error)}`) + } + } + break + } case "openExtensionSettings": { const settingsFilter = message.text || "" await vscode.commands.executeCommand( diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 70a58fbfd8..6350ed466d 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -1,5 +1,6 @@ // type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello' +import { GitCommit } from "../utils/git" import { ApiConfiguration, ModelInfo } from "./api" import { AutoApprovalSettings } from "./AutoApprovalSettings" import { BrowserSettings } from "./BrowserSettings" @@ -26,6 +27,7 @@ export interface ExtensionMessage { | "vsCodeLmModels" | "requestVsCodeLmModels" | "emailSubscribed" + | "commitSearchResults" text?: string action?: | "chatButtonClicked" @@ -46,6 +48,7 @@ export interface ExtensionMessage { openRouterModels?: Record openAiModels?: string[] mcpServers?: McpServer[] + commits?: GitCommit[] } export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 447193dd18..9b0f43ee9b 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -43,6 +43,7 @@ export interface WebviewMessage { | "accountLoginClicked" | "accountLogoutClicked" | "subscribeEmail" + | "searchCommits" // | "relaunchChromeDebugMode" text?: string disabled?: boolean diff --git a/src/shared/context-mentions.ts b/src/shared/context-mentions.ts index 5444b903ef..eff8f0a5c0 100644 --- a/src/shared/context-mentions.ts +++ b/src/shared/context-mentions.ts @@ -7,22 +7,22 @@ Mention regex: - **Regex Breakdown**: - `/@`: - - **@**: The mention must start with the '@' symbol. + - **@**: The mention must start with the '@' symbol. - `((?:\/|\w+:\/\/)[^\s]+?|problems\b)`: - - **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns. - - `(?:\/|\w+:\/\/)`: - - **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing. - - `\/`: - - **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'. - - `|`: Logical OR. - - `\w+:\/\/`: - - **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc. - - `[^\s]+?`: - - **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace. - - **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation. - - `|`: Logical OR. - - `problems\b`: + - **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns. + - `(?:\/|\w+:\/\/)`: + - **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing. + - `\/`: + - **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'. + - `|`: Logical OR. + - `\w+:\/\/`: + - **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc. + - `[^\s]+?`: + - **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace. + - **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation. + - `|`: Logical OR. + - `problems\b`: - **Exact Word ('problems')**: Matches the exact word 'problems'. - **Word Boundary (`\b`)**: Ensures that 'problems' is matched as a whole word and not as part of another word (e.g., 'problematic'). - `terminal\b`: @@ -30,23 +30,25 @@ Mention regex: - **Word Boundary (`\b`)**: Ensures that 'terminal' is matched as a whole word and not as part of another word (e.g., 'terminals'). - `(?=[.,;:!?]?(?=[\s\r\n]|$))`: - - **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match. - - `[.,;:!?]?`: - - **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks. - - `(?=[\s\r\n]|$)`: - - **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string. + - **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match. + - `[.,;:!?]?`: + - **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks. + - `(?=[\s\r\n]|$)`: + - **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string. - **Summary**: - The regex effectively matches: - - Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path). - - URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters). - - The exact word 'problems'. - - The exact word 'terminal'. + - Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path). + - URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters). + - The exact word 'problems'. + - The exact word 'terminal'. + - The exact word 'git-changes'. - It ensures that any trailing punctuation marks (such as ',', '.', '!', etc.) are not included in the matched mention, allowing the punctuation to follow the mention naturally in the text. - **Global Regex**: - `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string. */ -export const mentionRegex = /@((?:\/|\w+:\/\/)[^\s]+?|problems\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/ +export const mentionRegex = + /@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|terminal\b|git-changes\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/ export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g") diff --git a/src/utils/git.ts b/src/utils/git.ts new file mode 100644 index 0000000000..7ab6d67a07 --- /dev/null +++ b/src/utils/git.ts @@ -0,0 +1,177 @@ +import { exec } from "child_process" +import { promisify } from "util" + +const execAsync = promisify(exec) +const GIT_OUTPUT_LINE_LIMIT = 500 + +export interface GitCommit { + hash: string + shortHash: string + subject: string + author: string + date: string +} + +async function checkGitRepo(cwd: string): Promise { + try { + await execAsync("git rev-parse --git-dir", { cwd }) + return true + } catch (error) { + return false + } +} + +async function checkGitInstalled(): Promise { + try { + await execAsync("git --version") + return true + } catch (error) { + return false + } +} + +export async function searchCommits(query: string, cwd: string): Promise { + try { + const isInstalled = await checkGitInstalled() + if (!isInstalled) { + console.error("Git is not installed") + return [] + } + + const isRepo = await checkGitRepo(cwd) + if (!isRepo) { + console.error("Not a git repository") + return [] + } + + // Search commits by hash or message, limiting to 10 results + const { stdout } = await execAsync( + `git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--grep="${query}" --regexp-ignore-case`, + { cwd }, + ) + + let output = stdout + if (!output.trim() && /^[a-f0-9]+$/i.test(query)) { + // If no results from grep search and query looks like a hash, try searching by hash + const { stdout: hashStdout } = await execAsync( + `git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--author-date-order ${query}`, + { cwd }, + ).catch(() => ({ stdout: "" })) + + if (!hashStdout.trim()) { + return [] + } + + output = hashStdout + } + + const commits: GitCommit[] = [] + const lines = output + .trim() + .split("\n") + .filter((line) => line !== "--") + + for (let i = 0; i < lines.length; i += 5) { + commits.push({ + hash: lines[i], + shortHash: lines[i + 1], + subject: lines[i + 2], + author: lines[i + 3], + date: lines[i + 4], + }) + } + + return commits + } catch (error) { + console.error("Error searching commits:", error) + return [] + } +} + +export async function getCommitInfo(hash: string, cwd: string): Promise { + try { + const isInstalled = await checkGitInstalled() + if (!isInstalled) { + return "Git is not installed" + } + + const isRepo = await checkGitRepo(cwd) + if (!isRepo) { + return "Not a git repository" + } + + // Get commit info, stats, and diff separately + const { stdout: info } = await execAsync(`git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch ${hash}`, { + cwd, + }) + const [fullHash, shortHash, subject, author, date, body] = info.trim().split("\n") + + const { stdout: stats } = await execAsync(`git show --stat --format="" ${hash}`, { cwd }) + + const { stdout: diff } = await execAsync(`git show --format="" ${hash}`, { cwd }) + + const summary = [ + `Commit: ${shortHash} (${fullHash})`, + `Author: ${author}`, + `Date: ${date}`, + `\nMessage: ${subject}`, + body ? `\nDescription:\n${body}` : "", + "\nFiles Changed:", + stats.trim(), + "\nFull Changes:", + ].join("\n") + + const output = summary + "\n\n" + diff.trim() + return truncateOutput(output) + } catch (error) { + console.error("Error getting commit info:", error) + return `Failed to get commit info: ${error instanceof Error ? error.message : String(error)}` + } +} + +export async function getWorkingState(cwd: string): Promise { + try { + const isInstalled = await checkGitInstalled() + if (!isInstalled) { + return "Git is not installed" + } + + const isRepo = await checkGitRepo(cwd) + if (!isRepo) { + return "Not a git repository" + } + + // Get status of working directory + const { stdout: status } = await execAsync("git status --short", { cwd }) + if (!status.trim()) { + return "No changes in working directory" + } + + // Get all changes (both staged and unstaged) compared to HEAD + const { stdout: diff } = await execAsync("git diff HEAD", { cwd }) + const output = `Working directory changes:\n\n${status}\n\n${diff}`.trim() + return truncateOutput(output) + } catch (error) { + console.error("Error getting working state:", error) + return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}` + } +} + +function truncateOutput(content: string): string { + if (!GIT_OUTPUT_LINE_LIMIT) { + return content + } + + const lines = content.split("\n") + if (lines.length <= GIT_OUTPUT_LINE_LIMIT) { + return content + } + + const beforeLimit = Math.floor(GIT_OUTPUT_LINE_LIMIT * 0.2) // 20% of lines before + const afterLimit = GIT_OUTPUT_LINE_LIMIT - beforeLimit // remaining 80% after + return [ + ...lines.slice(0, beforeLimit), + `\n[...${lines.length - GIT_OUTPUT_LINE_LIMIT} lines omitted...]\n`, + ...lines.slice(-afterLimit), + ].join("\n") +} diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 4a06dc2e45..e1015aca29 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -13,6 +13,7 @@ "debounce": "^2.1.1", "fast-deep-equal": "^3.1.3", "fuse.js": "^7.0.0", + "fzf": "^0.5.2", "pretty-bytes": "^6.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -9825,6 +9826,12 @@ "node": ">=10" } }, + "node_modules/fzf": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz", + "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", + "license": "BSD-3-Clause" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/webview-ui/package.json b/webview-ui/package.json index 2f023747d1..8f15b44187 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -8,6 +8,7 @@ "debounce": "^2.1.1", "fast-deep-equal": "^3.1.3", "fuse.js": "^7.0.0", + "fzf": "^0.5.2", "pretty-bytes": "^6.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 180332028b..2e642f9a46 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -1,9 +1,10 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import DynamicTextArea from "react-textarea-autosize" -import { useClickAway, useWindowSize } from "react-use" +import { useClickAway, useEvent, useWindowSize } from "react-use" import styled from "styled-components" import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions" +import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { useExtensionState } from "../../context/ExtensionStateContext" import { ContextMenuOptionType, @@ -12,16 +13,15 @@ import { removeMention, shouldShowContextMenu, } from "../../utils/context-mentions" +import { useMetaKeyDetection, useShortcut } from "../../utils/hooks" import { validateApiConfiguration, validateModelId } from "../../utils/validate" import { vscode } from "../../utils/vscode" import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" import Thumbnails from "../common/Thumbnails" +import Tooltip from "../common/Tooltip" import ApiOptions, { normalizeApiConfiguration } from "../settings/ApiOptions" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import ContextMenu from "./ContextMenu" -import { useShortcut } from "../../utils/hooks" -import Tooltip from "../common/Tooltip" -import { useMetaKeyDetection } from "../../utils/hooks" interface ChatTextAreaProps { inputValue: string @@ -215,6 +215,8 @@ const ChatTextArea = forwardRef( ) => { const { filePaths, chatSettings, apiConfiguration, openRouterModels, platform } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) + const [gitCommits, setGitCommits] = useState([]) + const [thumbnailsHeight, setThumbnailsHeight] = useState(0) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) const [showContextMenu, setShowContextMenu] = useState(false) @@ -240,10 +242,40 @@ const ChatTextArea = forwardRef( // Add a ref to track previous menu state const prevShowModelSelector = useRef(showModelSelector) + // Fetch git commits when Git is selected or when typing a hash + useEffect(() => { + if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) { + vscode.postMessage({ + type: "searchCommits", + text: searchQuery || "", + }) + } + }, [selectedType, searchQuery]) + + const handleMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + switch (message.type) { + case "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}`, + })) || [] + setGitCommits(commits) + break + } + } + }, []) + + useEvent("message", handleMessage) + const queryItems = useMemo(() => { return [ { type: ContextMenuOptionType.Problems, value: "problems" }, { type: ContextMenuOptionType.Terminal, value: "terminal" }, + ...gitCommits, ...filePaths .map((file) => "/" + file) .map((path) => ({ @@ -251,7 +283,7 @@ const ChatTextArea = forwardRef( value: path, })), ] - }, [filePaths]) + }, [filePaths, gitCommits]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -275,7 +307,11 @@ const ChatTextArea = forwardRef( return } - if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) { + if ( + type === ContextMenuOptionType.File || + type === ContextMenuOptionType.Folder || + type === ContextMenuOptionType.Git + ) { if (!value) { setSelectedType(type) setSearchQuery("") @@ -296,6 +332,8 @@ const ChatTextArea = forwardRef( insertValue = "problems" } else if (type === ContextMenuOptionType.Terminal) { insertValue = "terminal" + } else if (type === ContextMenuOptionType.Git) { + insertValue = value || "" } const { newValue, mentionIndex } = insertMention(textAreaRef.current.value, cursorPosition, insertValue) @@ -898,7 +936,7 @@ const ChatTextArea = forwardRef( borderTop: 0, borderColor: "transparent", borderBottom: `${thumbnailsHeight + 6}px solid transparent`, - padding: "9px 49px 3px 9px", + padding: "9px 28px 3px 9px", }} /> = ({ return Paste URL to fetch contents case ContextMenuOptionType.NoResults: return No results found + case ContextMenuOptionType.Git: + if (option.value) { + return ( +
+ {option.label} + + {option.description} + +
+ ) + } else { + return Git Commits + } case ContextMenuOptionType.File: case ContextMenuOptionType.Folder: if (option.value) { @@ -91,6 +112,8 @@ const ContextMenu: React.FC = ({ return "terminal" case ContextMenuOptionType.URL: return "link" + case ContextMenuOptionType.Git: + return "git-commit" case ContextMenuOptionType.NoResults: return "info" default: @@ -165,7 +188,9 @@ const ContextMenu: React.FC = ({ /> {renderOptionContent(option)} - {(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) && + {(option.type === ContextMenuOptionType.File || + option.type === ContextMenuOptionType.Folder || + option.type === ContextMenuOptionType.Git) && !option.value && ( = ({ )} {(option.type === ContextMenuOptionType.Problems || option.type === ContextMenuOptionType.Terminal || - ((option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) && + ((option.type === ContextMenuOptionType.File || + option.type === ContextMenuOptionType.Folder || + option.type === ContextMenuOptionType.Git) && option.value)) && ( 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.Terminal }, + { type: ContextMenuOptionType.Git }, { type: ContextMenuOptionType.Folder }, { type: ContextMenuOptionType.File }, ] } 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", + }) + } 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", + }) } } + + // Create searchable strings array for fzf + const searchableItems = queryItems.map((item) => ({ + original: item, + searchStr: [item.value, item.label, item.description].filter(Boolean).join(" "), + })) + + // Initialize fzf instance for fuzzy search + const fzf = new Fzf(searchableItems, { + selector: (item) => item.searchStr, + }) + + // Get fuzzy matching items + const matchingItems = query ? fzf.find(query).map((result) => result.item.original) : [] + + // Separate matches by type + 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) { + const allItems = [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches] + + // Remove duplicates based on type and value + const seen = new Set() + const deduped = allItems.filter((item) => { + const key = `${item.type}-${item.value}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + + return deduped + } + + return [{ type: ContextMenuOptionType.NoResults }] } export function shouldShowContextMenu(text: string, position: number): boolean {