mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-23 05:41:10 -05:00
Add a Git section to the context mentions
This commit is contained in:
@@ -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) {
|
||||
|
||||
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 { 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<void> {
|
||||
if (!mention) {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mention.startsWith("/")) {
|
||||
const relPath = mention.slice(1)
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
const absPath = path.resolve(cwd, relPath)
|
||||
if (mention.endsWith("/")) {
|
||||
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
|
||||
// vscode.commands.executeCommand("vscode.openFolder", , { forceNewWindow: false }) opens in new window
|
||||
} else {
|
||||
openFile(absPath)
|
||||
}
|
||||
@@ -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<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`
|
||||
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`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<string, ModelInfo>
|
||||
openAiModels?: string[]
|
||||
mcpServers?: McpServer[]
|
||||
commits?: GitCommit[]
|
||||
}
|
||||
|
||||
export interface ExtensionState {
|
||||
|
||||
@@ -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<string, any>
|
||||
query?: string
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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)}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user