From 6e834d2fc3def044432b8e99635d859251a146f8 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 6 Jan 2025 11:22:47 -0500 Subject: [PATCH 1/2] Add missing deepseek check --- webview-ui/src/context/ExtensionStateContext.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 8572b79..52ee1e4 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -100,6 +100,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode config.lmStudioModelId, config.geminiApiKey, config.openAiNativeApiKey, + config.deepSeekApiKey, ].some((key) => key !== undefined) : false setShowWelcome(!hasKey) From 7e9ea7ac2862c7689195cdeed753f8dc3cd0fdaa Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 6 Jan 2025 01:50:06 -0500 Subject: [PATCH 2/2] Add a Git section to the context mentions --- .changeset/two-camels-jam.md | 5 + README.md | 1 + src/core/Cline.ts | 21 +- src/core/mentions/__tests__/index.test.ts | 155 ++++++++++++ src/core/mentions/index.ts | 37 ++- src/core/webview/ClineProvider.ts | 19 ++ .../misc/__tests__/extract-text.test.ts | 237 +++++++++++------- src/integrations/misc/extract-text.ts | 34 +++ src/shared/ExtensionMessage.ts | 3 + src/shared/WebviewMessage.ts | 2 + src/shared/context-mentions.ts | 89 +++++-- src/utils/git.ts | 166 ++++++++++++ .../src/components/chat/ChatTextArea.tsx | 105 ++++---- .../src/components/chat/ContextMenu.tsx | 54 +++- webview-ui/src/services/GitService.ts | 46 ++++ .../utils/__tests__/context-mentions.test.ts | 130 ++++++++++ webview-ui/src/utils/context-mentions.ts | 90 ++++++- 17 files changed, 987 insertions(+), 207 deletions(-) create mode 100644 .changeset/two-camels-jam.md create mode 100644 src/core/mentions/__tests__/index.test.ts create mode 100644 src/utils/git.ts create mode 100644 webview-ui/src/services/GitService.ts create mode 100644 webview-ui/src/utils/__tests__/context-mentions.test.ts diff --git a/.changeset/two-camels-jam.md b/.changeset/two-camels-jam.md new file mode 100644 index 0000000..6f165f7 --- /dev/null +++ b/.changeset/two-camels-jam.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add a Git section to the context mentions diff --git a/README.md b/README.md index 88cf173..bf2a627 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f - Drag and drop images into chats - Delete messages from chats +- @-mention Git commits to include their context in the chat - "Enhance prompt" button (OpenRouter models only for now) - Sound effects for feedback - Option to use browsers of different sizes and adjust screenshot quality diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 6a73a5f..84bf47c 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -12,7 +12,7 @@ import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api" import { ApiStream } from "../api/transform/stream" import { DiffViewProvider } from "../integrations/editor/DiffViewProvider" import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown" -import { extractTextFromFile, addLineNumbers, stripLineNumbers, everyLineHasLineNumbers } from "../integrations/misc/extract-text" +import { extractTextFromFile, addLineNumbers, stripLineNumbers, everyLineHasLineNumbers, truncateOutput } from "../integrations/misc/extract-text" import { TerminalManager } from "../integrations/terminal/TerminalManager" import { UrlContentFetcher } from "../services/browser/UrlContentFetcher" import { listFiles } from "../services/glob/list-files" @@ -716,22 +716,6 @@ export class Cline { } }) - const getFormattedOutput = async () => { - const { terminalOutputLineLimit } = await this.providerRef.deref()?.getState() ?? {} - const limit = terminalOutputLineLimit ?? 0 - - if (limit > 0 && lines.length > limit) { - const beforeLimit = Math.floor(limit * 0.2) // 20% of lines before - const afterLimit = limit - beforeLimit // remaining 80% after - return [ - ...lines.slice(0, beforeLimit), - `\n[...${lines.length - limit} lines omitted...]\n`, - ...lines.slice(-afterLimit) - ].join('\n') - } - return lines.join('\n') - } - let completed = false process.once("completed", () => { completed = true @@ -750,7 +734,8 @@ export class Cline { // grouping command_output messages despite any gaps anyways) await delay(50) - const output = await getFormattedOutput() + const { terminalOutputLineLimit } = await this.providerRef.deref()?.getState() ?? {} + const output = truncateOutput(lines.join('\n'), terminalOutputLineLimit) const result = output.trim() if (userFeedback) { diff --git a/src/core/mentions/__tests__/index.test.ts b/src/core/mentions/__tests__/index.test.ts new file mode 100644 index 0000000..e816726 --- /dev/null +++ b/src/core/mentions/__tests__/index.test.ts @@ -0,0 +1,155 @@ +// Create mock vscode module before importing anything +const createMockUri = (scheme: string, path: string) => ({ + scheme, + authority: '', + path, + query: '', + fragment: '', + fsPath: path, + with: jest.fn(), + toString: () => path, + toJSON: () => ({ + scheme, + authority: '', + path, + query: '', + fragment: '' + }) +}) + +const mockExecuteCommand = jest.fn() +const mockOpenExternal = jest.fn() +const mockShowErrorMessage = jest.fn() + +const mockVscode = { + workspace: { + workspaceFolders: [{ + uri: { fsPath: "/test/workspace" } + }] + }, + window: { + showErrorMessage: mockShowErrorMessage, + showInformationMessage: jest.fn(), + showWarningMessage: jest.fn(), + createTextEditorDecorationType: jest.fn(), + createOutputChannel: jest.fn(), + createWebviewPanel: jest.fn(), + activeTextEditor: undefined + }, + commands: { + executeCommand: mockExecuteCommand + }, + env: { + openExternal: mockOpenExternal + }, + Uri: { + parse: jest.fn((url: string) => createMockUri('https', url)), + file: jest.fn((path: string) => createMockUri('file', path)) + }, + Position: jest.fn(), + Range: jest.fn(), + TextEdit: jest.fn(), + WorkspaceEdit: jest.fn(), + DiagnosticSeverity: { + Error: 0, + Warning: 1, + Information: 2, + Hint: 3 + } +} + +// Mock modules +jest.mock('vscode', () => mockVscode) +jest.mock("../../../services/browser/UrlContentFetcher") +jest.mock("../../../utils/git") + +// Now import the modules that use the mocks +import { parseMentions, openMention } from "../index" +import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher" +import * as git from "../../../utils/git" + +describe("mentions", () => { + const mockCwd = "/test/workspace" + let mockUrlContentFetcher: UrlContentFetcher + + beforeEach(() => { + jest.clearAllMocks() + + // Create a mock instance with just the methods we need + mockUrlContentFetcher = { + launchBrowser: jest.fn().mockResolvedValue(undefined), + closeBrowser: jest.fn().mockResolvedValue(undefined), + urlToMarkdown: jest.fn().mockResolvedValue(""), + } as unknown as UrlContentFetcher + }) + + describe("parseMentions", () => { + it("should parse git commit mentions", async () => { + const commitHash = "abc1234" + const commitInfo = `abc1234 Fix bug in parser + +Author: John Doe +Date: Mon Jan 5 23:50:06 2025 -0500 + +Detailed commit message with multiple lines +- Fixed parsing issue +- Added tests` + + jest.mocked(git.getCommitInfo).mockResolvedValue(commitInfo) + + const result = await parseMentions( + `Check out this commit @${commitHash}`, + mockCwd, + mockUrlContentFetcher + ) + + expect(result).toContain(`'${commitHash}' (see below for commit info)`) + expect(result).toContain(``) + expect(result).toContain(commitInfo) + }) + + it("should handle errors fetching git info", async () => { + const commitHash = "abc1234" + const errorMessage = "Failed to get commit info" + + jest.mocked(git.getCommitInfo).mockRejectedValue(new Error(errorMessage)) + + const result = await parseMentions( + `Check out this commit @${commitHash}`, + mockCwd, + mockUrlContentFetcher + ) + + expect(result).toContain(`'${commitHash}' (see below for commit info)`) + expect(result).toContain(``) + expect(result).toContain(`Error fetching commit info: ${errorMessage}`) + }) + }) + + describe("openMention", () => { + it("should handle file paths and problems", async () => { + await openMention("/path/to/file") + expect(mockExecuteCommand).not.toHaveBeenCalled() + expect(mockOpenExternal).not.toHaveBeenCalled() + expect(mockShowErrorMessage).toHaveBeenCalledWith("Could not open file!") + + await openMention("problems") + expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems") + }) + + it("should handle URLs", async () => { + const url = "https://example.com" + await openMention(url) + const mockUri = mockVscode.Uri.parse(url) + expect(mockOpenExternal).toHaveBeenCalled() + const calledArg = mockOpenExternal.mock.calls[0][0] + expect(calledArg).toEqual(expect.objectContaining({ + scheme: mockUri.scheme, + authority: mockUri.authority, + path: mockUri.path, + query: mockUri.query, + fragment: mockUri.fragment + })) + }) + }) +}) \ No newline at end of file diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index 1c9c122..cf5bdea 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -2,27 +2,28 @@ import * as vscode from "vscode" import * as path from "path" import { openFile } from "../../integrations/misc/open-file" import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" -import { mentionRegexGlobal } from "../../shared/context-mentions" +import { mentionRegexGlobal, formatGitSuggestion, type MentionSuggestion } from "../../shared/context-mentions" import fs from "fs/promises" import { extractTextFromFile } from "../../integrations/misc/extract-text" import { isBinaryFile } from "isbinaryfile" import { diagnosticsToProblemsString } from "../../integrations/diagnostics" +import { getCommitInfo, getWorkingState } from "../../utils/git" -export function openMention(mention?: string): void { +export async function openMention(mention?: string): Promise { 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) } @@ -40,12 +41,16 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher if (mention.startsWith("http")) { return `'${mention}' (see below for site content)` } else if (mention.startsWith("/")) { - const mentionPath = mention.slice(1) // Remove the leading '/' + const mentionPath = mention.slice(1) return mentionPath.endsWith("/") ? `'${mentionPath}' (see below for folder content)` : `'${mentionPath}' (see below for file content)` } else if (mention === "problems") { return `Workspace Problems (see below for diagnostics)` + } 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 }) @@ -99,6 +104,20 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher } catch (error) { parsedText += `\n\n\nError fetching diagnostics: ${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` + } } } @@ -137,7 +156,6 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise folderContent += `${linePrefix}${entry.name}\n` const filePath = path.join(mentionPath, entry.name) const absoluteFilePath = path.resolve(absPath, entry.name) - // const relativeFilePath = path.relative(cwd, absoluteFilePath); fileContentPromises.push( (async () => { try { @@ -154,7 +172,6 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise ) } else if (entry.isDirectory()) { folderContent += `${linePrefix}${entry.name}/\n` - // not recursively getting folder contents } else { folderContent += `${linePrefix}${entry.name}\n` } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 025cb88..0c62ba6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -24,6 +24,7 @@ import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound" import { enhancePrompt } from "../../utils/enhance-prompt" +import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -732,6 +733,24 @@ 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.query || "", cwd) + await this.postMessageToWebview({ + type: "commitSearchResults", + commits + }) + } catch (error) { + console.error("Error searching commits:", error) + vscode.window.showErrorMessage("Failed to search commits") + } + } + break + } } }, null, diff --git a/src/integrations/misc/__tests__/extract-text.test.ts b/src/integrations/misc/__tests__/extract-text.test.ts index ced548f..5b91324 100644 --- a/src/integrations/misc/__tests__/extract-text.test.ts +++ b/src/integrations/misc/__tests__/extract-text.test.ts @@ -1,109 +1,176 @@ -import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from '../extract-text'; +import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers, truncateOutput } from '../extract-text'; describe('addLineNumbers', () => { - it('should add line numbers starting from 1 by default', () => { - const input = 'line 1\nline 2\nline 3'; - const expected = '1 | line 1\n2 | line 2\n3 | line 3'; - expect(addLineNumbers(input)).toBe(expected); - }); + it('should add line numbers starting from 1 by default', () => { + const input = 'line 1\nline 2\nline 3'; + const expected = '1 | line 1\n2 | line 2\n3 | line 3'; + expect(addLineNumbers(input)).toBe(expected); + }); - it('should add line numbers starting from specified line number', () => { - const input = 'line 1\nline 2\nline 3'; - const expected = '10 | line 1\n11 | line 2\n12 | line 3'; - expect(addLineNumbers(input, 10)).toBe(expected); - }); + it('should add line numbers starting from specified line number', () => { + const input = 'line 1\nline 2\nline 3'; + const expected = '10 | line 1\n11 | line 2\n12 | line 3'; + expect(addLineNumbers(input, 10)).toBe(expected); + }); - it('should handle empty content', () => { - expect(addLineNumbers('')).toBe('1 | '); - expect(addLineNumbers('', 5)).toBe('5 | '); - }); + it('should handle empty content', () => { + expect(addLineNumbers('')).toBe('1 | '); + expect(addLineNumbers('', 5)).toBe('5 | '); + }); - it('should handle single line content', () => { - expect(addLineNumbers('single line')).toBe('1 | single line'); - expect(addLineNumbers('single line', 42)).toBe('42 | single line'); - }); + it('should handle single line content', () => { + expect(addLineNumbers('single line')).toBe('1 | single line'); + expect(addLineNumbers('single line', 42)).toBe('42 | single line'); + }); - it('should pad line numbers based on the highest line number', () => { - const input = 'line 1\nline 2'; - // When starting from 99, highest line will be 100, so needs 3 spaces padding - const expected = ' 99 | line 1\n100 | line 2'; - expect(addLineNumbers(input, 99)).toBe(expected); - }); + it('should pad line numbers based on the highest line number', () => { + const input = 'line 1\nline 2'; + // When starting from 99, highest line will be 100, so needs 3 spaces padding + const expected = ' 99 | line 1\n100 | line 2'; + expect(addLineNumbers(input, 99)).toBe(expected); + }); }); describe('everyLineHasLineNumbers', () => { - it('should return true for content with line numbers', () => { - const input = '1 | line one\n2 | line two\n3 | line three'; - expect(everyLineHasLineNumbers(input)).toBe(true); - }); + it('should return true for content with line numbers', () => { + const input = '1 | line one\n2 | line two\n3 | line three'; + expect(everyLineHasLineNumbers(input)).toBe(true); + }); - it('should return true for content with padded line numbers', () => { - const input = ' 1 | line one\n 2 | line two\n 3 | line three'; - expect(everyLineHasLineNumbers(input)).toBe(true); - }); + it('should return true for content with padded line numbers', () => { + const input = ' 1 | line one\n 2 | line two\n 3 | line three'; + expect(everyLineHasLineNumbers(input)).toBe(true); + }); - it('should return false for content without line numbers', () => { - const input = 'line one\nline two\nline three'; - expect(everyLineHasLineNumbers(input)).toBe(false); - }); + it('should return false for content without line numbers', () => { + const input = 'line one\nline two\nline three'; + expect(everyLineHasLineNumbers(input)).toBe(false); + }); - it('should return false for mixed content', () => { - const input = '1 | line one\nline two\n3 | line three'; - expect(everyLineHasLineNumbers(input)).toBe(false); - }); + it('should return false for mixed content', () => { + const input = '1 | line one\nline two\n3 | line three'; + expect(everyLineHasLineNumbers(input)).toBe(false); + }); - it('should handle empty content', () => { - expect(everyLineHasLineNumbers('')).toBe(false); - }); + it('should handle empty content', () => { + expect(everyLineHasLineNumbers('')).toBe(false); + }); - it('should return false for content with pipe but no line numbers', () => { - const input = 'a | b\nc | d'; - expect(everyLineHasLineNumbers(input)).toBe(false); - }); + it('should return false for content with pipe but no line numbers', () => { + const input = 'a | b\nc | d'; + expect(everyLineHasLineNumbers(input)).toBe(false); + }); }); describe('stripLineNumbers', () => { - it('should strip line numbers from content', () => { - const input = '1 | line one\n2 | line two\n3 | line three'; - const expected = 'line one\nline two\nline three'; - expect(stripLineNumbers(input)).toBe(expected); - }); + it('should strip line numbers from content', () => { + const input = '1 | line one\n2 | line two\n3 | line three'; + const expected = 'line one\nline two\nline three'; + expect(stripLineNumbers(input)).toBe(expected); + }); - it('should strip padded line numbers', () => { - const input = ' 1 | line one\n 2 | line two\n 3 | line three'; - const expected = 'line one\nline two\nline three'; - expect(stripLineNumbers(input)).toBe(expected); - }); + it('should strip padded line numbers', () => { + const input = ' 1 | line one\n 2 | line two\n 3 | line three'; + const expected = 'line one\nline two\nline three'; + expect(stripLineNumbers(input)).toBe(expected); + }); - it('should handle content without line numbers', () => { - const input = 'line one\nline two\nline three'; - expect(stripLineNumbers(input)).toBe(input); - }); + it('should handle content without line numbers', () => { + const input = 'line one\nline two\nline three'; + expect(stripLineNumbers(input)).toBe(input); + }); - it('should handle empty content', () => { - expect(stripLineNumbers('')).toBe(''); - }); + it('should handle empty content', () => { + expect(stripLineNumbers('')).toBe(''); + }); - it('should preserve content with pipe but no line numbers', () => { - const input = 'a | b\nc | d'; - expect(stripLineNumbers(input)).toBe(input); - }); + it('should preserve content with pipe but no line numbers', () => { + const input = 'a | b\nc | d'; + expect(stripLineNumbers(input)).toBe(input); + }); - it('should handle windows-style line endings', () => { - const input = '1 | line one\r\n2 | line two\r\n3 | line three'; - const expected = 'line one\r\nline two\r\nline three'; - expect(stripLineNumbers(input)).toBe(expected); - }); + it('should handle windows-style line endings', () => { + const input = '1 | line one\r\n2 | line two\r\n3 | line three'; + const expected = 'line one\r\nline two\r\nline three'; + expect(stripLineNumbers(input)).toBe(expected); + }); - it('should handle content with varying line number widths', () => { - const input = ' 1 | line one\n 10 | line two\n100 | line three'; - const expected = 'line one\nline two\nline three'; - expect(stripLineNumbers(input)).toBe(expected); - }); + it('should handle content with varying line number widths', () => { + const input = ' 1 | line one\n 10 | line two\n100 | line three'; + const expected = 'line one\nline two\nline three'; + expect(stripLineNumbers(input)).toBe(expected); + }); +}); - it('should preserve indentation after line numbers', () => { - const input = '1 | indented line\n2 | another indented'; - const expected = ' indented line\n another indented'; - expect(stripLineNumbers(input)).toBe(expected); - }); -}); \ No newline at end of file +describe('truncateOutput', () => { + it('returns original content when no line limit provided', () => { + const content = 'line1\nline2\nline3' + expect(truncateOutput(content)).toBe(content) + }) + + it('returns original content when lines are under limit', () => { + const content = 'line1\nline2\nline3' + expect(truncateOutput(content, 5)).toBe(content) + }) + + it('truncates content with 20/80 split when over limit', () => { + // Create 25 lines of content + const lines = Array.from({ length: 25 }, (_, i) => `line${i + 1}`) + const content = lines.join('\n') + + // Set limit to 10 lines + const result = truncateOutput(content, 10) + + // Should keep: + // - First 2 lines (20% of 10) + // - Last 8 lines (80% of 10) + // - Omission indicator in between + const expectedLines = [ + 'line1', + 'line2', + '', + '[...15 lines omitted...]', + '', + 'line18', + 'line19', + 'line20', + 'line21', + 'line22', + 'line23', + 'line24', + 'line25' + ] + expect(result).toBe(expectedLines.join('\n')) + }) + + it('handles empty content', () => { + expect(truncateOutput('', 10)).toBe('') + }) + + it('handles single line content', () => { + expect(truncateOutput('single line', 10)).toBe('single line') + }) + + it('handles windows-style line endings', () => { + // Create content with windows line endings + const lines = Array.from({ length: 15 }, (_, i) => `line${i + 1}`) + const content = lines.join('\r\n') + + const result = truncateOutput(content, 5) + + // Should keep first line (20% of 5 = 1) and last 4 lines (80% of 5 = 4) + // Split result by either \r\n or \n to normalize line endings + const resultLines = result.split(/\r?\n/) + const expectedLines = [ + 'line1', + '', + '[...10 lines omitted...]', + '', + 'line12', + 'line13', + 'line14', + 'line15' + ] + expect(resultLines).toEqual(expectedLines) + }) +}) \ No newline at end of file diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index fb303ec..ee70652 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -87,4 +87,38 @@ export function stripLineNumbers(content: string): string { // Join back with original line endings const lineEnding = content.includes('\r\n') ? '\r\n' : '\n' return processedLines.join(lineEnding) +} + +/** + * Truncates multi-line output while preserving context from both the beginning and end. + * When truncation is needed, it keeps 20% of the lines from the start and 80% from the end, + * with a clear indicator of how many lines were omitted in between. + * + * @param content The multi-line string to truncate + * @param lineLimit Optional maximum number of lines to keep. If not provided or 0, returns the original content + * @returns The truncated string with an indicator of omitted lines, or the original content if no truncation needed + * + * @example + * // With 10 line limit on 25 lines of content: + * // - Keeps first 2 lines (20% of 10) + * // - Keeps last 8 lines (80% of 10) + * // - Adds "[...15 lines omitted...]" in between + */ +export function truncateOutput(content: string, lineLimit?: number): string { + if (!lineLimit) { + return content + } + + const lines = content.split('\n') + if (lines.length <= lineLimit) { + return content + } + + const beforeLimit = Math.floor(lineLimit * 0.2) // 20% of lines before + const afterLimit = lineLimit - beforeLimit // remaining 80% after + return [ + ...lines.slice(0, beforeLimit), + `\n[...${lines.length - lineLimit} lines omitted...]\n`, + ...lines.slice(-afterLimit) + ].join('\n') } \ No newline at end of file diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 887945f..30df793 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -3,6 +3,7 @@ import { ApiConfiguration, ModelInfo } from "./api" import { HistoryItem } from "./HistoryItem" import { McpServer } from "./mcp" +import { GitCommit } from "../utils/git" // webview will hold state export interface ExtensionMessage { @@ -21,6 +22,7 @@ export interface ExtensionMessage { | "openAiModels" | "mcpServers" | "enhancedPrompt" + | "commitSearchResults" text?: string action?: | "chatButtonClicked" @@ -39,6 +41,7 @@ export interface ExtensionMessage { openRouterModels?: Record openAiModels?: string[] mcpServers?: McpServer[] + commits?: GitCommit[] } export interface ExtensionState { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 111faac..48eeb4a 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -51,6 +51,7 @@ export interface WebviewMessage { | "deleteMessage" | "terminalOutputLineLimit" | "mcpEnabled" + | "searchCommits" text?: string disabled?: boolean askResponse?: ClineAskResponse @@ -65,6 +66,7 @@ export interface WebviewMessage { alwaysAllow?: boolean dataUrls?: string[] values?: Record + query?: string } export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" diff --git a/src/shared/context-mentions.ts b/src/shared/context-mentions.ts index 3912868..ed2a657 100644 --- a/src/shared/context-mentions.ts +++ b/src/shared/context-mentions.ts @@ -7,42 +7,79 @@ 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`: - - **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'). + - `((?:\/|\w+:\/\/)[^\s]+?|problems\b|git-changes\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'). + - `|`: Logical OR. + - `problems\b`: + - **Exact Word ('git-changes')**: Matches the exact word 'git-changes'. + - **Word Boundary (`\b`)**: Ensures that 'git-changes' is matched as a whole word and not as part of another word. - `(?=[.,;:!?]?(?=[\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'. + - 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 '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)(?=[.,;:!?]?(?=[\s\r\n]|$))/ +export const mentionRegex = /@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/ export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g") + +export interface MentionSuggestion { + type: 'file' | 'folder' | 'git' | 'problems' + label: string + description?: string + value: string + icon?: string +} + +export interface GitMentionSuggestion extends MentionSuggestion { + type: 'git' + hash: string + shortHash: string + subject: string + author: string + date: string +} + +export function formatGitSuggestion(commit: { hash: string; shortHash: string; subject: string; author: string; date: string }): GitMentionSuggestion { + return { + type: 'git', + label: commit.subject, + description: `${commit.shortHash} by ${commit.author} on ${commit.date}`, + value: commit.hash, + icon: '$(git-commit)', // VSCode git commit icon + hash: commit.hash, + shortHash: commit.shortHash, + subject: commit.subject, + author: commit.author, + date: commit.date + } +} diff --git a/src/utils/git.ts b/src/utils/git.ts new file mode 100644 index 0000000..0cd957f --- /dev/null +++ b/src/utils/git.ts @@ -0,0 +1,166 @@ +import { exec } from "child_process" +import { promisify } from "util" +import { truncateOutput } from "../integrations/misc/extract-text" + +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, GIT_OUTPUT_LINE_LIMIT) + } 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 lineLimit = GIT_OUTPUT_LINE_LIMIT + const output = `Working directory changes:\n\n${status}\n\n${diff}`.trim() + return truncateOutput(output, lineLimit) + } catch (error) { + console.error("Error getting working state:", error) + return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}` + } +} \ No newline at end of file diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 64b45cd..2078013 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -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( ) => { const { filePaths, apiConfiguration } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) + const [gitCommits, setGitCommits] = useState([]) // Handle enhanced prompt response useEffect(() => { @@ -54,6 +55,15 @@ const ChatTextArea = forwardRef( 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( const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false) const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) const contextMenuContainerRef = useRef(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( value: path, })), ] - }, [filePaths]) + }, [filePaths, gitCommits]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -130,7 +151,9 @@ 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("") @@ -149,6 +172,8 @@ const ChatTextArea = forwardRef( 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( 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( (event: React.KeyboardEvent) => { if (showContextMenu) { if (event.key === "Escape") { - // event.preventDefault() setSelectedType(null) setSelectedMenuIndex(3) // File by default return @@ -356,19 +379,17 @@ const ChatTextArea = forwardRef( 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( }) 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( 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( 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( )}
- {apiConfiguration?.apiProvider === "openrouter" && ( -
- {isEnhancingPrompt && Enhancing prompt...} - !textAreaDisabled && handleEnhancePrompt()} - style={{ fontSize: 16.5 }} - /> -
- )} - !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} /> - !textAreaDisabled && onSend()} style={{ fontSize: 15 }} /> + {apiConfiguration?.apiProvider === "openrouter" && ( +
+ {isEnhancingPrompt && Enhancing prompt...} + !textAreaDisabled && handleEnhancePrompt()} + style={{ fontSize: 16.5 }} + /> +
+ )} + !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} /> + !textAreaDisabled && onSend()} style={{ fontSize: 15 }} />
diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index 81a3047..535cf77 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -52,6 +52,26 @@ const ContextMenu: React.FC = ({ 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) { @@ -87,6 +107,8 @@ const ContextMenu: React.FC = ({ 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 = ({ 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) => (
= ({ flex: 1, minWidth: 0, overflow: "hidden", + paddingTop: 0 }}> {renderOptionContent(option)}
- {(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) && - !option.value && ( - - )} + {((option.type === ContextMenuOptionType.File || + option.type === ContextMenuOptionType.Folder || + option.type === ContextMenuOptionType.Git) && + !option.value) && ( + + )} {(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)) && ( { + 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((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() \ No newline at end of file diff --git a/webview-ui/src/utils/__tests__/context-mentions.test.ts b/webview-ui/src/utils/__tests__/context-mentions.test.ts new file mode 100644 index 0000000..6f37921 --- /dev/null +++ b/webview-ui/src/utils/__tests__/context-mentions.test.ts @@ -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) + }) +}) \ No newline at end of file diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index a79aefb..6d846bf 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -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 {