mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
Merge pull request #284 from RooVetGit/git_mentions
Add a Git section to the context mentions
This commit is contained in:
5
.changeset/two-camels-jam.md
Normal file
5
.changeset/two-camels-jam.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"roo-cline": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a Git section to the context mentions
|
||||||
@@ -6,6 +6,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
|
|||||||
|
|
||||||
- Drag and drop images into chats
|
- Drag and drop images into chats
|
||||||
- Delete messages from chats
|
- Delete messages from chats
|
||||||
|
- @-mention Git commits to include their context in the chat
|
||||||
- "Enhance prompt" button (OpenRouter models only for now)
|
- "Enhance prompt" button (OpenRouter models only for now)
|
||||||
- Sound effects for feedback
|
- Sound effects for feedback
|
||||||
- Option to use browsers of different sizes and adjust screenshot quality
|
- Option to use browsers of different sizes and adjust screenshot quality
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
|
|||||||
import { ApiStream } from "../api/transform/stream"
|
import { ApiStream } from "../api/transform/stream"
|
||||||
import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
|
import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
|
||||||
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
|
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 { TerminalManager } from "../integrations/terminal/TerminalManager"
|
||||||
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
|
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
|
||||||
import { listFiles } from "../services/glob/list-files"
|
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
|
let completed = false
|
||||||
process.once("completed", () => {
|
process.once("completed", () => {
|
||||||
completed = true
|
completed = true
|
||||||
@@ -750,7 +734,8 @@ export class Cline {
|
|||||||
// grouping command_output messages despite any gaps anyways)
|
// grouping command_output messages despite any gaps anyways)
|
||||||
await delay(50)
|
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()
|
const result = output.trim()
|
||||||
|
|
||||||
if (userFeedback) {
|
if (userFeedback) {
|
||||||
|
|||||||
155
src/core/mentions/__tests__/index.test.ts
Normal file
155
src/core/mentions/__tests__/index.test.ts
Normal file
@@ -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(`<git_commit hash="${commitHash}">`)
|
||||||
|
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(`<git_commit hash="${commitHash}">`)
|
||||||
|
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
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,27 +2,28 @@ import * as vscode from "vscode"
|
|||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { openFile } from "../../integrations/misc/open-file"
|
import { openFile } from "../../integrations/misc/open-file"
|
||||||
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
|
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 fs from "fs/promises"
|
||||||
import { extractTextFromFile } from "../../integrations/misc/extract-text"
|
import { extractTextFromFile } from "../../integrations/misc/extract-text"
|
||||||
import { isBinaryFile } from "isbinaryfile"
|
import { isBinaryFile } from "isbinaryfile"
|
||||||
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
|
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
|
||||||
|
import { getCommitInfo, getWorkingState } from "../../utils/git"
|
||||||
|
|
||||||
export function openMention(mention?: string): void {
|
export async function openMention(mention?: string): Promise<void> {
|
||||||
if (!mention) {
|
if (!mention) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||||
|
if (!cwd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (mention.startsWith("/")) {
|
if (mention.startsWith("/")) {
|
||||||
const relPath = mention.slice(1)
|
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)
|
const absPath = path.resolve(cwd, relPath)
|
||||||
if (mention.endsWith("/")) {
|
if (mention.endsWith("/")) {
|
||||||
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
|
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
|
||||||
// vscode.commands.executeCommand("vscode.openFolder", , { forceNewWindow: false }) opens in new window
|
|
||||||
} else {
|
} else {
|
||||||
openFile(absPath)
|
openFile(absPath)
|
||||||
}
|
}
|
||||||
@@ -40,12 +41,16 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
|
|||||||
if (mention.startsWith("http")) {
|
if (mention.startsWith("http")) {
|
||||||
return `'${mention}' (see below for site content)`
|
return `'${mention}' (see below for site content)`
|
||||||
} else if (mention.startsWith("/")) {
|
} else if (mention.startsWith("/")) {
|
||||||
const mentionPath = mention.slice(1) // Remove the leading '/'
|
const mentionPath = mention.slice(1)
|
||||||
return mentionPath.endsWith("/")
|
return mentionPath.endsWith("/")
|
||||||
? `'${mentionPath}' (see below for folder content)`
|
? `'${mentionPath}' (see below for folder content)`
|
||||||
: `'${mentionPath}' (see below for file content)`
|
: `'${mentionPath}' (see below for file content)`
|
||||||
} else if (mention === "problems") {
|
} else if (mention === "problems") {
|
||||||
return `Workspace Problems (see below for diagnostics)`
|
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
|
return match
|
||||||
})
|
})
|
||||||
@@ -99,6 +104,20 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
parsedText += `\n\n<workspace_diagnostics>\nError fetching diagnostics: ${error.message}\n</workspace_diagnostics>`
|
parsedText += `\n\n<workspace_diagnostics>\nError fetching diagnostics: ${error.message}\n</workspace_diagnostics>`
|
||||||
}
|
}
|
||||||
|
} else if (mention === "git-changes") {
|
||||||
|
try {
|
||||||
|
const workingState = await getWorkingState(cwd)
|
||||||
|
parsedText += `\n\n<git_working_state>\n${workingState}\n</git_working_state>`
|
||||||
|
} catch (error) {
|
||||||
|
parsedText += `\n\n<git_working_state>\nError fetching working state: ${error.message}\n</git_working_state>`
|
||||||
|
}
|
||||||
|
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
|
||||||
|
try {
|
||||||
|
const commitInfo = await getCommitInfo(mention, cwd)
|
||||||
|
parsedText += `\n\n<git_commit hash="${mention}">\n${commitInfo}\n</git_commit>`
|
||||||
|
} catch (error) {
|
||||||
|
parsedText += `\n\n<git_commit hash="${mention}">\nError fetching commit info: ${error.message}\n</git_commit>`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +156,6 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
|
|||||||
folderContent += `${linePrefix}${entry.name}\n`
|
folderContent += `${linePrefix}${entry.name}\n`
|
||||||
const filePath = path.join(mentionPath, entry.name)
|
const filePath = path.join(mentionPath, entry.name)
|
||||||
const absoluteFilePath = path.resolve(absPath, entry.name)
|
const absoluteFilePath = path.resolve(absPath, entry.name)
|
||||||
// const relativeFilePath = path.relative(cwd, absoluteFilePath);
|
|
||||||
fileContentPromises.push(
|
fileContentPromises.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -154,7 +172,6 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
|
|||||||
)
|
)
|
||||||
} else if (entry.isDirectory()) {
|
} else if (entry.isDirectory()) {
|
||||||
folderContent += `${linePrefix}${entry.name}/\n`
|
folderContent += `${linePrefix}${entry.name}/\n`
|
||||||
// not recursively getting folder contents
|
|
||||||
} else {
|
} else {
|
||||||
folderContent += `${linePrefix}${entry.name}\n`
|
folderContent += `${linePrefix}${entry.name}\n`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { getNonce } from "./getNonce"
|
|||||||
import { getUri } from "./getUri"
|
import { getUri } from "./getUri"
|
||||||
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
|
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
|
||||||
import { enhancePrompt } from "../../utils/enhance-prompt"
|
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
|
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
|
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,
|
null,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from '../extract-text';
|
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers, truncateOutput } from '../extract-text';
|
||||||
|
|
||||||
describe('addLineNumbers', () => {
|
describe('addLineNumbers', () => {
|
||||||
it('should add line numbers starting from 1 by default', () => {
|
it('should add line numbers starting from 1 by default', () => {
|
||||||
@@ -100,10 +100,77 @@ describe('stripLineNumbers', () => {
|
|||||||
const expected = 'line one\nline two\nline three';
|
const expected = 'line one\nline two\nline three';
|
||||||
expect(stripLineNumbers(input)).toBe(expected);
|
expect(stripLineNumbers(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should preserve indentation after line numbers', () => {
|
describe('truncateOutput', () => {
|
||||||
const input = '1 | indented line\n2 | another indented';
|
it('returns original content when no line limit provided', () => {
|
||||||
const expected = ' indented line\n another indented';
|
const content = 'line1\nline2\nline3'
|
||||||
expect(stripLineNumbers(input)).toBe(expected);
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -88,3 +88,37 @@ export function stripLineNumbers(content: string): string {
|
|||||||
const lineEnding = content.includes('\r\n') ? '\r\n' : '\n'
|
const lineEnding = content.includes('\r\n') ? '\r\n' : '\n'
|
||||||
return processedLines.join(lineEnding)
|
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')
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { ApiConfiguration, ModelInfo } from "./api"
|
import { ApiConfiguration, ModelInfo } from "./api"
|
||||||
import { HistoryItem } from "./HistoryItem"
|
import { HistoryItem } from "./HistoryItem"
|
||||||
import { McpServer } from "./mcp"
|
import { McpServer } from "./mcp"
|
||||||
|
import { GitCommit } from "../utils/git"
|
||||||
|
|
||||||
// webview will hold state
|
// webview will hold state
|
||||||
export interface ExtensionMessage {
|
export interface ExtensionMessage {
|
||||||
@@ -21,6 +22,7 @@ export interface ExtensionMessage {
|
|||||||
| "openAiModels"
|
| "openAiModels"
|
||||||
| "mcpServers"
|
| "mcpServers"
|
||||||
| "enhancedPrompt"
|
| "enhancedPrompt"
|
||||||
|
| "commitSearchResults"
|
||||||
text?: string
|
text?: string
|
||||||
action?:
|
action?:
|
||||||
| "chatButtonClicked"
|
| "chatButtonClicked"
|
||||||
@@ -39,6 +41,7 @@ export interface ExtensionMessage {
|
|||||||
openRouterModels?: Record<string, ModelInfo>
|
openRouterModels?: Record<string, ModelInfo>
|
||||||
openAiModels?: string[]
|
openAiModels?: string[]
|
||||||
mcpServers?: McpServer[]
|
mcpServers?: McpServer[]
|
||||||
|
commits?: GitCommit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionState {
|
export interface ExtensionState {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface WebviewMessage {
|
|||||||
| "deleteMessage"
|
| "deleteMessage"
|
||||||
| "terminalOutputLineLimit"
|
| "terminalOutputLineLimit"
|
||||||
| "mcpEnabled"
|
| "mcpEnabled"
|
||||||
|
| "searchCommits"
|
||||||
text?: string
|
text?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
askResponse?: ClineAskResponse
|
askResponse?: ClineAskResponse
|
||||||
@@ -65,6 +66,7 @@ export interface WebviewMessage {
|
|||||||
alwaysAllow?: boolean
|
alwaysAllow?: boolean
|
||||||
dataUrls?: string[]
|
dataUrls?: string[]
|
||||||
values?: Record<string, any>
|
values?: Record<string, any>
|
||||||
|
query?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Mention regex:
|
|||||||
- `/@`:
|
- `/@`:
|
||||||
- **@**: The mention must start with the '@' symbol.
|
- **@**: The mention must start with the '@' symbol.
|
||||||
|
|
||||||
- `((?:\/|\w+:\/\/)[^\s]+?|problems\b)`:
|
- `((?:\/|\w+:\/\/)[^\s]+?|problems\b|git-changes\b)`:
|
||||||
- **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns.
|
- **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns.
|
||||||
- `(?:\/|\w+:\/\/)`:
|
- `(?:\/|\w+:\/\/)`:
|
||||||
- **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing.
|
- **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing.
|
||||||
@@ -25,6 +25,10 @@ Mention regex:
|
|||||||
- `problems\b`:
|
- `problems\b`:
|
||||||
- **Exact Word ('problems')**: Matches the exact word 'problems'.
|
- **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').
|
- **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]|$))`:
|
- `(?=[.,;:!?]?(?=[\s\r\n]|$))`:
|
||||||
- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
|
- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
|
||||||
@@ -38,11 +42,44 @@ Mention regex:
|
|||||||
- Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path).
|
- 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).
|
- URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters).
|
||||||
- The exact word 'problems'.
|
- 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.
|
- 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**:
|
- **Global Regex**:
|
||||||
- `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string.
|
- `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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
166
src/utils/git.ts
Normal file
166
src/utils/git.ts
Normal file
@@ -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<boolean> {
|
||||||
|
try {
|
||||||
|
await execAsync('git rev-parse --git-dir', { cwd })
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkGitInstalled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execAsync('git --version')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCommits(query: string, cwd: string): Promise<GitCommit[]> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
|
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
|
||||||
import ContextMenu from "./ContextMenu"
|
import ContextMenu from "./ContextMenu"
|
||||||
import Thumbnails from "../common/Thumbnails"
|
import Thumbnails from "../common/Thumbnails"
|
||||||
|
|
||||||
import { vscode } from "../../utils/vscode"
|
import { vscode } from "../../utils/vscode"
|
||||||
|
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
|
||||||
|
|
||||||
interface ChatTextAreaProps {
|
interface ChatTextAreaProps {
|
||||||
inputValue: string
|
inputValue: string
|
||||||
@@ -46,6 +46,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
) => {
|
) => {
|
||||||
const { filePaths, apiConfiguration } = useExtensionState()
|
const { filePaths, apiConfiguration } = useExtensionState()
|
||||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||||
|
const [gitCommits, setGitCommits] = useState<any[]>([])
|
||||||
|
|
||||||
// Handle enhanced prompt response
|
// Handle enhanced prompt response
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,6 +55,15 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
if (message.type === 'enhancedPrompt' && message.text) {
|
if (message.type === 'enhancedPrompt' && message.text) {
|
||||||
setInputValue(message.text)
|
setInputValue(message.text)
|
||||||
setIsEnhancingPrompt(false)
|
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)
|
window.addEventListener('message', messageHandler)
|
||||||
@@ -73,9 +83,19 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
|
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
|
||||||
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
|
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
|
||||||
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
|
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
|
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(() => {
|
const handleEnhancePrompt = useCallback(() => {
|
||||||
if (!textAreaDisabled) {
|
if (!textAreaDisabled) {
|
||||||
const trimmedInput = inputValue.trim()
|
const trimmedInput = inputValue.trim()
|
||||||
@@ -96,6 +116,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
const queryItems = useMemo(() => {
|
const queryItems = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{ type: ContextMenuOptionType.Problems, value: "problems" },
|
{ type: ContextMenuOptionType.Problems, value: "problems" },
|
||||||
|
...gitCommits,
|
||||||
...filePaths
|
...filePaths
|
||||||
.map((file) => "/" + file)
|
.map((file) => "/" + file)
|
||||||
.map((path) => ({
|
.map((path) => ({
|
||||||
@@ -103,7 +124,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
value: path,
|
value: path,
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
}, [filePaths])
|
}, [filePaths, gitCommits])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -130,7 +151,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
|
if (type === ContextMenuOptionType.File ||
|
||||||
|
type === ContextMenuOptionType.Folder ||
|
||||||
|
type === ContextMenuOptionType.Git) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setSelectedType(type)
|
setSelectedType(type)
|
||||||
setSearchQuery("")
|
setSearchQuery("")
|
||||||
@@ -149,6 +172,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
insertValue = value || ""
|
insertValue = value || ""
|
||||||
} else if (type === ContextMenuOptionType.Problems) {
|
} else if (type === ContextMenuOptionType.Problems) {
|
||||||
insertValue = "problems"
|
insertValue = "problems"
|
||||||
|
} else if (type === ContextMenuOptionType.Git) {
|
||||||
|
insertValue = value || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const { newValue, mentionIndex } = insertMention(
|
const { newValue, mentionIndex } = insertMention(
|
||||||
@@ -161,7 +186,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
|
const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
|
||||||
setCursorPosition(newCursorPosition)
|
setCursorPosition(newCursorPosition)
|
||||||
setIntendedCursorPosition(newCursorPosition)
|
setIntendedCursorPosition(newCursorPosition)
|
||||||
// textAreaRef.current.focus()
|
|
||||||
|
|
||||||
// scroll to cursor
|
// scroll to cursor
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -179,7 +203,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (showContextMenu) {
|
if (showContextMenu) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
// event.preventDefault()
|
|
||||||
setSelectedType(null)
|
setSelectedType(null)
|
||||||
setSelectedMenuIndex(3) // File by default
|
setSelectedMenuIndex(3) // File by default
|
||||||
return
|
return
|
||||||
@@ -356,19 +379,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
setShowContextMenu(false)
|
setShowContextMenu(false)
|
||||||
|
|
||||||
// Scroll to new cursor position
|
// 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(() => {
|
setTimeout(() => {
|
||||||
if (textAreaRef.current) {
|
if (textAreaRef.current) {
|
||||||
textAreaRef.current.blur()
|
textAreaRef.current.blur()
|
||||||
textAreaRef.current.focus()
|
textAreaRef.current.focus()
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 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
|
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 imageItems = Array.from(items).filter((item) => {
|
||||||
const [type, subtype] = item.type.split("/")
|
const [type, subtype] = item.type.split("/")
|
||||||
return type === "image" && acceptedTypes.includes(subtype)
|
return type === "image" && acceptedTypes.includes(subtype)
|
||||||
@@ -397,7 +418,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
})
|
})
|
||||||
const imageDataArray = await Promise.all(imagePromises)
|
const imageDataArray = await Promise.all(imagePromises)
|
||||||
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
|
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) {
|
if (dataUrls.length > 0) {
|
||||||
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
|
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
|
||||||
} else {
|
} else {
|
||||||
@@ -602,7 +622,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
color: "var(--vscode-input-foreground)",
|
color: "var(--vscode-input-foreground)",
|
||||||
//border: "1px solid var(--vscode-input-border)",
|
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
fontFamily: "var(--vscode-font-family)",
|
fontFamily: "var(--vscode-font-family)",
|
||||||
fontSize: "var(--vscode-editor-font-size)",
|
fontSize: "var(--vscode-editor-font-size)",
|
||||||
@@ -610,18 +629,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||||||
resize: "none",
|
resize: "none",
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
overflowY: "scroll",
|
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,
|
borderLeft: 0,
|
||||||
borderRight: 0,
|
borderRight: 0,
|
||||||
borderTop: 0,
|
borderTop: 0,
|
||||||
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
|
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
|
||||||
borderColor: "transparent",
|
borderColor: "transparent",
|
||||||
padding: "9px 9px 25px 9px",
|
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,
|
cursor: textAreaDisabled ? "not-allowed" : undefined,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
|
|||||||
@@ -52,6 +52,26 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
return <span>Paste URL to fetch contents</span>
|
return <span>Paste URL to fetch contents</span>
|
||||||
case ContextMenuOptionType.NoResults:
|
case ContextMenuOptionType.NoResults:
|
||||||
return <span>No results found</span>
|
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.File:
|
||||||
case ContextMenuOptionType.Folder:
|
case ContextMenuOptionType.Folder:
|
||||||
if (option.value) {
|
if (option.value) {
|
||||||
@@ -87,6 +107,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
return "warning"
|
return "warning"
|
||||||
case ContextMenuOptionType.URL:
|
case ContextMenuOptionType.URL:
|
||||||
return "link"
|
return "link"
|
||||||
|
case ContextMenuOptionType.Git:
|
||||||
|
return "git-commit"
|
||||||
case ContextMenuOptionType.NoResults:
|
case ContextMenuOptionType.NoResults:
|
||||||
return "info"
|
return "info"
|
||||||
default:
|
default:
|
||||||
@@ -121,7 +143,6 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
maxHeight: "200px",
|
maxHeight: "200px",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
}}>
|
}}>
|
||||||
{/* Can't use virtuoso since it requires fixed height and menu height is dynamic based on # of items */}
|
|
||||||
{filteredOptions.map((option, index) => (
|
{filteredOptions.map((option, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${option.type}-${option.value || index}`}
|
key={`${option.type}-${option.value || index}`}
|
||||||
@@ -147,15 +168,23 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
paddingTop: 0
|
||||||
}}>
|
}}>
|
||||||
<i
|
<i
|
||||||
className={`codicon codicon-${getIconForOption(option)}`}
|
className={`codicon codicon-${getIconForOption(option)}`}
|
||||||
style={{ marginRight: "8px", flexShrink: 0, fontSize: "14px" }}
|
style={{
|
||||||
|
marginRight: "6px",
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: "14px",
|
||||||
|
marginTop: 0
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{renderOptionContent(option)}
|
{renderOptionContent(option)}
|
||||||
</div>
|
</div>
|
||||||
{(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) &&
|
{((option.type === ContextMenuOptionType.File ||
|
||||||
!option.value && (
|
option.type === ContextMenuOptionType.Folder ||
|
||||||
|
option.type === ContextMenuOptionType.Git) &&
|
||||||
|
!option.value) && (
|
||||||
<i
|
<i
|
||||||
className="codicon codicon-chevron-right"
|
className="codicon codicon-chevron-right"
|
||||||
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
|
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
|
||||||
@@ -163,7 +192,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
)}
|
)}
|
||||||
{(option.type === ContextMenuOptionType.Problems ||
|
{(option.type === ContextMenuOptionType.Problems ||
|
||||||
((option.type === ContextMenuOptionType.File ||
|
((option.type === ContextMenuOptionType.File ||
|
||||||
option.type === ContextMenuOptionType.Folder) &&
|
option.type === ContextMenuOptionType.Folder ||
|
||||||
|
option.type === ContextMenuOptionType.Git) &&
|
||||||
option.value)) && (
|
option.value)) && (
|
||||||
<i
|
<i
|
||||||
className="codicon codicon-add"
|
className="codicon codicon-add"
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||||||
config.lmStudioModelId,
|
config.lmStudioModelId,
|
||||||
config.geminiApiKey,
|
config.geminiApiKey,
|
||||||
config.openAiNativeApiKey,
|
config.openAiNativeApiKey,
|
||||||
|
config.deepSeekApiKey,
|
||||||
].some((key) => key !== undefined)
|
].some((key) => key !== undefined)
|
||||||
: false
|
: false
|
||||||
setShowWelcome(!hasKey)
|
setShowWelcome(!hasKey)
|
||||||
|
|||||||
46
webview-ui/src/services/GitService.ts
Normal file
46
webview-ui/src/services/GitService.ts
Normal 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()
|
||||||
130
webview-ui/src/utils/__tests__/context-mentions.test.ts
Normal file
130
webview-ui/src/utils/__tests__/context-mentions.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -51,12 +51,16 @@ export enum ContextMenuOptionType {
|
|||||||
Folder = "folder",
|
Folder = "folder",
|
||||||
Problems = "problems",
|
Problems = "problems",
|
||||||
URL = "url",
|
URL = "url",
|
||||||
|
Git = "git",
|
||||||
NoResults = "noResults",
|
NoResults = "noResults",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextMenuQueryItem {
|
export interface ContextMenuQueryItem {
|
||||||
type: ContextMenuOptionType
|
type: ContextMenuOptionType
|
||||||
value?: string
|
value?: string
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
icon?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContextMenuOptions(
|
export function getContextMenuOptions(
|
||||||
@@ -64,6 +68,14 @@ export function getContextMenuOptions(
|
|||||||
selectedType: ContextMenuOptionType | null = null,
|
selectedType: ContextMenuOptionType | null = null,
|
||||||
queryItems: ContextMenuQueryItem[],
|
queryItems: ContextMenuQueryItem[],
|
||||||
): ContextMenuQueryItem[] {
|
): ContextMenuQueryItem[] {
|
||||||
|
const workingChanges: ContextMenuQueryItem = {
|
||||||
|
type: ContextMenuOptionType.Git,
|
||||||
|
value: "git-changes",
|
||||||
|
label: "Working changes",
|
||||||
|
description: "Current uncommitted changes",
|
||||||
|
icon: "$(git-commit)"
|
||||||
|
}
|
||||||
|
|
||||||
if (query === "") {
|
if (query === "") {
|
||||||
if (selectedType === ContextMenuOptionType.File) {
|
if (selectedType === ContextMenuOptionType.File) {
|
||||||
const files = queryItems
|
const files = queryItems
|
||||||
@@ -79,31 +91,89 @@ export function getContextMenuOptions(
|
|||||||
return folders.length > 0 ? folders : [{ type: ContextMenuOptionType.NoResults }]
|
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 [
|
return [
|
||||||
{ type: ContextMenuOptionType.URL },
|
|
||||||
{ type: ContextMenuOptionType.Problems },
|
{ type: ContextMenuOptionType.Problems },
|
||||||
|
{ type: ContextMenuOptionType.URL },
|
||||||
{ type: ContextMenuOptionType.Folder },
|
{ type: ContextMenuOptionType.Folder },
|
||||||
{ type: ContextMenuOptionType.File },
|
{ type: ContextMenuOptionType.File },
|
||||||
|
{ type: ContextMenuOptionType.Git },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const lowerQuery = query.toLowerCase()
|
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")) {
|
if (query.startsWith("http")) {
|
||||||
return [{ type: ContextMenuOptionType.URL, value: query }]
|
suggestions.push({ type: ContextMenuOptionType.URL, value: query })
|
||||||
} else {
|
}
|
||||||
const matchingItems = queryItems.filter((item) => item.value?.toLowerCase().includes(lowerQuery))
|
|
||||||
|
|
||||||
if (matchingItems.length > 0) {
|
// Add exact SHA matches to suggestions
|
||||||
return matchingItems.map((item) => ({
|
if (/^[a-f0-9]{7,40}$/i.test(lowerQuery)) {
|
||||||
type: item.type,
|
const exactMatches = queryItems.filter((item) =>
|
||||||
value: item.value,
|
item.type === ContextMenuOptionType.Git &&
|
||||||
}))
|
item.value?.toLowerCase() === lowerQuery
|
||||||
|
)
|
||||||
|
if (exactMatches.length > 0) {
|
||||||
|
suggestions.push(...exactMatches)
|
||||||
} else {
|
} else {
|
||||||
|
// 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 }]
|
return [{ type: ContextMenuOptionType.NoResults }]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldShowContextMenu(text: string, position: number): boolean {
|
export function shouldShowContextMenu(text: string, position: number): boolean {
|
||||||
const beforeCursor = text.slice(0, position)
|
const beforeCursor = text.slice(0, position)
|
||||||
|
|||||||
Reference in New Issue
Block a user