feat: Add SVN commit support with GUI interface similar to Git commits

- Add SVN utility functions in src/utils/svn.ts mirroring git.ts functionality
- Add SvnCommit type and svnCommitSearchResults to ExtensionMessage
- Add searchSvnCommits message type to WebviewMessage
- Update webviewMessageHandler to handle SVN commit searches
- Update context-mentions to support SVN revision patterns (r123, @svn-changes)
- Update ChatTextArea to trigger SVN searches and display results
- Add comprehensive test suite for SVN functionality
- Implement SVN repository info extraction and working state detection

Fixes #6100
This commit is contained in:
Roo Code 2025-07-23 09:24:05 +00:00
parent 9956cc1f48
commit f891aef3b6
9 changed files with 786 additions and 6 deletions

View file

@ -7,6 +7,7 @@ import { isBinaryFile } from "isbinaryfile"
import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
import { getCommitInfo, getWorkingState } from "../../utils/git"
import { getSvnCommitInfo, getSvnWorkingState } from "../../utils/svn"
import { getWorkspacePath } from "../../utils/path"
import { openFile } from "../../integrations/misc/open-file"
@ -95,8 +96,12 @@ export async function parseMentions(
return `Workspace Problems (see below for diagnostics)`
} else if (mention === "git-changes") {
return `Working directory changes (see below for details)`
} else if (mention === "svn-changes") {
return `SVN 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)`
} else if (/^r?\d+$/.test(mention)) {
return `SVN revision '${mention}' (see below for revision info)`
} else if (mention === "terminal") {
return `Terminal Output (see below for output)`
}
@ -177,6 +182,13 @@ export async function parseMentions(
} catch (error) {
parsedText += `\n\n<git_working_state>\nError fetching working state: ${error.message}\n</git_working_state>`
}
} else if (mention === "svn-changes") {
try {
const workingState = await getSvnWorkingState(cwd)
parsedText += `\n\n<svn_working_state>\n${workingState}\n</svn_working_state>`
} catch (error) {
parsedText += `\n\n<svn_working_state>\nError fetching SVN working state: ${error.message}\n</svn_working_state>`
}
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
try {
const commitInfo = await getCommitInfo(mention, cwd)
@ -184,6 +196,13 @@ export async function parseMentions(
} catch (error) {
parsedText += `\n\n<git_commit hash="${mention}">\nError fetching commit info: ${error.message}\n</git_commit>`
}
} else if (/^r?\d+$/.test(mention)) {
try {
const commitInfo = await getSvnCommitInfo(mention, cwd)
parsedText += `\n\n<svn_revision revision="${mention}">\n${commitInfo}\n</svn_revision>`
} catch (error) {
parsedText += `\n\n<svn_revision revision="${mention}">\nError fetching SVN revision info: ${error.message}\n</svn_revision>`
}
} else if (mention === "terminal") {
try {
const terminalOutput = await getLatestTerminalOutput()

View file

@ -37,6 +37,7 @@ import { fileExistsAtPath } from "../../utils/fs"
import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts"
import { singleCompletionHandler } from "../../utils/single-completion-handler"
import { searchCommits } from "../../utils/git"
import { searchSvnCommits } from "../../utils/svn"
import { exportSettings, importSettingsWithFeedback } from "../config/importExport"
import { getOpenAiModels } from "../../api/providers/openai"
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
@ -1384,6 +1385,24 @@ export const webviewMessageHandler = async (
}
break
}
case "searchSvnCommits": {
const cwd = provider.cwd
if (cwd) {
try {
const svnCommits = await searchSvnCommits(message.query || "", cwd)
await provider.postMessageToWebview({
type: "svnCommitSearchResults",
svnCommits,
})
} catch (error) {
provider.log(
`Error searching SVN commits: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage(t("common:errors.search_commits"))
}
}
break
}
case "searchFiles": {
const workspacePath = getWorkspacePath()

View file

@ -13,6 +13,7 @@ import type {
} from "@roo-code/types"
import { GitCommit } from "../utils/git"
import { SvnCommit } from "../utils/svn"
import { McpServer } from "./mcp"
import { Mode } from "./modes"
@ -61,6 +62,7 @@ export interface ExtensionMessage {
| "mcpServers"
| "enhancedPrompt"
| "commitSearchResults"
| "svnCommitSearchResults"
| "listApiConfig"
| "routerModels"
| "openAiModels"
@ -137,6 +139,7 @@ export interface ExtensionMessage {
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
mcpServers?: McpServer[]
commits?: GitCommit[]
svnCommits?: SvnCommit[]
listApiConfig?: ProviderSettingsEntry[]
mode?: Mode
customMode?: ModeConfig

View file

@ -129,6 +129,7 @@ export interface WebviewMessage {
| "mcpEnabled"
| "enableMcpServerCreation"
| "searchCommits"
| "searchSvnCommits"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "setApiConfigPassword"

View file

@ -46,7 +46,8 @@ Mention regex:
- 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'.
- The exact word 'terminal'.
- The exact word 'svn-changes'.
- The exact word 'terminal'.
- 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**:
@ -54,11 +55,11 @@ Mention regex:
*/
export const mentionRegex =
/(?<!\\)@((?:\/|\w+:\/\/)(?:[^\s\\]|\\ )+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
/(?<!\\)@((?:\/|\w+:\/\/)(?:[^\s\\]|\\ )+?|[a-f0-9]{7,40}\b|r?\d+\b|problems\b|git-changes\b|svn-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
export interface MentionSuggestion {
type: "file" | "folder" | "git" | "problems"
type: "file" | "folder" | "git" | "svn" | "problems"
label: string
description?: string
value: string
@ -95,6 +96,33 @@ export function formatGitSuggestion(commit: {
}
}
export interface SvnMentionSuggestion extends MentionSuggestion {
type: "svn"
revision: string
author: string
date: string
message: string
}
export function formatSvnSuggestion(commit: {
revision: string
author: string
date: string
message: string
}): SvnMentionSuggestion {
return {
type: "svn",
label: commit.message,
description: `${commit.revision} by ${commit.author} on ${commit.date}`,
value: commit.revision,
icon: "$(git-commit)", // Using same icon as Git for consistency
revision: commit.revision,
author: commit.author,
date: commit.date,
message: commit.message,
}
}
// Helper function to unescape paths with backslash-escaped spaces
export function unescapeSpaces(path: string): string {
return path.replace(/\\ /g, " ")

View file

@ -0,0 +1,322 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import * as child_process from "child_process"
import { promisify } from "util"
import {
checkSvnInstalled,
searchSvnCommits,
getSvnCommitInfo,
getSvnWorkingState,
extractSvnRepositoryName,
getSvnRepositoryInfo,
} from "../svn"
// Mock child_process.exec
vi.mock("child_process", () => ({
exec: vi.fn(),
}))
// Mock promisify to return a proper async function
vi.mock("util", () => ({
promisify: vi.fn((fn) => {
return (...args: any[]) => {
return new Promise((resolve, reject) => {
const callback = (err: any, result: any) => {
if (err) reject(err)
else resolve(result)
}
// Call the original function with all args plus our callback
fn(...args, callback)
})
}
}),
}))
// Mock fs/promises
vi.mock("fs/promises", () => ({
access: vi.fn(),
}))
describe("SVN utilities", () => {
let mockExec: any
beforeEach(() => {
vi.clearAllMocks()
mockExec = vi.mocked(child_process.exec)
})
describe("checkSvnInstalled", () => {
it("should return true when SVN is installed", async () => {
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || options
cb(null, { stdout: "svn, version 1.14.0", stderr: "" })
})
const result = await checkSvnInstalled()
expect(result).toBe(true)
expect(mockExec).toHaveBeenCalled()
})
it("should return false when SVN is not installed", async () => {
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || options
cb(new Error("command not found"), null)
})
const result = await checkSvnInstalled()
expect(result).toBe(false)
})
})
describe("extractSvnRepositoryName", () => {
it("should extract repository name from standard layout", () => {
expect(extractSvnRepositoryName("https://svn.example.com/repos/myproject/trunk")).toBe("myproject")
expect(extractSvnRepositoryName("https://svn.example.com/repos/myproject/branches/feature")).toBe(
"myproject",
)
expect(extractSvnRepositoryName("https://svn.example.com/repos/myproject/tags/v1.0")).toBe("myproject")
})
it("should extract repository name from simple SVN URL", () => {
expect(extractSvnRepositoryName("https://svn.example.com/svn/myproject")).toBe("myproject")
expect(extractSvnRepositoryName("svn://svn.example.com/svn/myproject")).toBe("myproject")
})
it("should handle URLs with trailing slashes", () => {
expect(extractSvnRepositoryName("https://svn.example.com/repos/myproject/trunk/")).toBe("myproject")
expect(extractSvnRepositoryName("https://svn.example.com/svn/myproject/")).toBe("myproject")
})
it("should return empty string for invalid URLs", () => {
expect(extractSvnRepositoryName("")).toBe("")
expect(extractSvnRepositoryName("not-a-url")).toBe("")
})
})
describe("searchSvnCommits", () => {
it("should return commits when searching by message", async () => {
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || options
if (cmd.includes("svn info")) {
cb(null, { stdout: "Path: .", stderr: "" })
} else if (cmd.includes("svn log")) {
const xmlOutput = `<?xml version="1.0" encoding="UTF-8"?>
<log>
<logentry revision="123">
<author>john</author>
<date>2024-01-15T10:30:00.000000Z</date>
<msg>Fix bug in login</msg>
</logentry>
<logentry revision="122">
<author>jane</author>
<date>2024-01-14T15:45:00.000000Z</date>
<msg>Add new feature</msg>
</logentry>
</log>`
cb(null, { stdout: xmlOutput, stderr: "" })
} else if (cmd === "svn --version") {
cb(null, { stdout: "svn, version 1.14.0", stderr: "" })
}
})
const commits = await searchSvnCommits("bug", "/test/path")
expect(commits).toHaveLength(1)
expect(commits[0]).toEqual({
revision: "r123",
author: "john",
date: "2024-01-15",
message: "Fix bug in login",
})
})
it("should return specific commit when searching by revision", async () => {
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || options
if (cmd.includes("svn info")) {
cb(null, { stdout: "Path: .", stderr: "" })
} else if (cmd.includes("svn log -r 123")) {
const xmlOutput = `<?xml version="1.0" encoding="UTF-8"?>
<log>
<logentry revision="123">
<author>john</author>
<date>2024-01-15T10:30:00.000000Z</date>
<msg>Fix bug in login</msg>
</logentry>
</log>`
cb(null, { stdout: xmlOutput, stderr: "" })
} else if (cmd === "svn --version") {
cb(null, { stdout: "svn, version 1.14.0", stderr: "" })
}
})
const commits = await searchSvnCommits("r123", "/test/path")
expect(commits).toHaveLength(1)
expect(commits[0].revision).toBe("r123")
})
it("should return empty array when SVN is not installed", async () => {
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || options
cb(new Error("command not found"), null)
})
const commits = await searchSvnCommits("test", "/test/path")
expect(commits).toEqual([])
})
})
describe("getSvnCommitInfo", () => {
it("should return commit info with diff", async () => {
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || options
if (cmd.includes("svn info")) {
cb(null, { stdout: "Path: .", stderr: "" })
} else if (cmd.includes("svn log -r 123")) {
const xmlOutput = `<?xml version="1.0" encoding="UTF-8"?>
<log>
<logentry revision="123">
<author>john</author>
<date>2024-01-15T10:30:00.000000Z</date>
<msg>Fix bug in login</msg>
<paths>
<path action="M">/trunk/src/login.js</path>
</paths>
</logentry>
</log>`
cb(null, { stdout: xmlOutput, stderr: "" })
} else if (cmd.includes("svn diff -c 123")) {
const diffOutput = `Index: src/login.js
===================================================================
--- src/login.js (revision 122)
+++ src/login.js (revision 123)
@@ -10,7 +10,7 @@
function login(username, password) {
- if (username && password) {
+ if (username && password && password.length > 0) {
return authenticate(username, password);
}
return false;
}`
cb(null, { stdout: diffOutput, stderr: "" })
} else if (cmd === "svn --version") {
cb(null, { stdout: "svn, version 1.14.0", stderr: "" })
}
})
const info = await getSvnCommitInfo("r123", "/test/path")
expect(info).toContain("Revision: r123")
expect(info).toContain("Author: john")
expect(info).toContain("Message: Fix bug in login")
expect(info).toContain("M: /trunk/src/login.js")
expect(info).toContain("function login(username, password)")
})
it("should handle errors gracefully", async () => {
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || options
if (cmd === "svn --version") {
cb(null, { stdout: "svn, version 1.14.0", stderr: "" })
} else {
cb(new Error("Not an SVN repository"), null)
}
})
const info = await getSvnCommitInfo("r123", "/test/path")
expect(info).toBe("Not an SVN repository")
})
})
describe("getSvnWorkingState", () => {
it("should return working directory changes", async () => {
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || options
if (cmd === "svn --version") {
cb(null, { stdout: "svn, version 1.14.0", stderr: "" })
} else if (cmd.includes("svn info")) {
cb(null, { stdout: "Path: .", stderr: "" })
} else if (cmd === "svn status") {
cb(null, { stdout: "M src/app.js\nA src/new-file.js", stderr: "" })
} else if (cmd === "svn diff") {
const diffOutput = `Index: src/app.js
===================================================================
--- src/app.js (revision 123)
+++ src/app.js (working copy)
@@ -1,5 +1,5 @@
const express = require('express');
-const app = express();
+const app = express(); // Initialize Express app`
cb(null, { stdout: diffOutput, stderr: "" })
}
})
const state = await getSvnWorkingState("/test/path")
expect(state).toContain("Working directory changes:")
expect(state).toContain("M src/app.js")
expect(state).toContain("A src/new-file.js")
expect(state).toContain("// Initialize Express app")
})
it("should return message when no changes", async () => {
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || options
if (cmd.includes("svn info")) {
cb(null, { stdout: "Path: .", stderr: "" })
} else if (cmd === "svn status") {
cb(null, { stdout: "", stderr: "" })
} else if (cmd === "svn --version") {
cb(null, { stdout: "svn, version 1.14.0", stderr: "" })
}
})
const state = await getSvnWorkingState("/test/path")
expect(state).toBe("No changes in working directory")
})
})
describe("getSvnRepositoryInfo", () => {
it("should extract repository info from svn info command", async () => {
// Mock fs.access to simulate .svn directory exists
const fs = await import("fs/promises")
vi.mocked(fs.access).mockResolvedValue(undefined)
mockExec.mockImplementation((cmd: string, options: any, callback?: any) => {
const cb = callback || (typeof options === "function" ? options : undefined)
if (cb) {
if (cmd === "svn info --xml") {
const xmlOutput = `<?xml version="1.0" encoding="UTF-8"?>
<info>
<entry>
<url>https://svn.example.com/repos/myproject/trunk</url>
<repository>
<root>https://svn.example.com/repos/myproject</root>
</repository>
<commit revision="123">
</commit>
</entry>
</info>`
cb(null, { stdout: xmlOutput, stderr: "" })
} else {
cb(null, { stdout: "", stderr: "" })
}
}
})
const info = await getSvnRepositoryInfo("/test/path")
expect(info).toEqual({
repositoryUrl: "https://svn.example.com/repos/myproject/trunk",
repositoryName: "myproject",
repositoryRoot: "https://svn.example.com/repos/myproject",
revision: "123",
})
})
it("should return empty object for non-SVN directory", async () => {
const fs = await import("fs/promises")
vi.mocked(fs.access).mockRejectedValue(new Error("Not found"))
const info = await getSvnRepositoryInfo("/test/path")
expect(info).toEqual({})
})
})
})

317
src/utils/svn.ts Normal file
View file

@ -0,0 +1,317 @@
import * as vscode from "vscode"
import * as path from "path"
import { promises as fs } from "fs"
import { exec } from "child_process"
import { promisify } from "util"
import { truncateOutput } from "../integrations/misc/extract-text"
const execAsync = promisify(exec)
const SVN_OUTPUT_LINE_LIMIT = 500
export interface SvnRepositoryInfo {
repositoryUrl?: string
repositoryName?: string
repositoryRoot?: string
revision?: string
}
export interface SvnCommit {
revision: string
author: string
date: string
message: string
files?: string[]
}
/**
* Extracts SVN repository information from the workspace's .svn directory
* @param workspaceRoot The root path of the workspace
* @returns SVN repository information or empty object if not an SVN repository
*/
export async function getSvnRepositoryInfo(workspaceRoot: string): Promise<SvnRepositoryInfo> {
try {
const svnDir = path.join(workspaceRoot, ".svn")
// Check if .svn directory exists
try {
await fs.access(svnDir)
} catch {
// Not an SVN repository
return {}
}
const svnInfo: SvnRepositoryInfo = {}
try {
// Use svn info command to get repository information
const { stdout } = await execAsync("svn info --xml", { cwd: workspaceRoot })
// Parse XML output
const urlMatch = stdout.match(/<url>([^<]+)<\/url>/)
const rootMatch = stdout.match(/<root>([^<]+)<\/root>/)
const revisionMatch = stdout.match(/<commit[^>]*revision="(\d+)"/)
if (urlMatch && urlMatch[1]) {
svnInfo.repositoryUrl = urlMatch[1]
const repositoryName = extractSvnRepositoryName(urlMatch[1])
if (repositoryName) {
svnInfo.repositoryName = repositoryName
}
}
if (rootMatch && rootMatch[1]) {
svnInfo.repositoryRoot = rootMatch[1]
}
if (revisionMatch && revisionMatch[1]) {
svnInfo.revision = revisionMatch[1]
}
} catch (error) {
// Ignore svn info errors
}
return svnInfo
} catch (error) {
// Return empty object on any error
return {}
}
}
/**
* Extracts repository name from an SVN URL
* @param url The SVN URL
* @returns Repository name or undefined
*/
export function extractSvnRepositoryName(url: string): string {
try {
// Extract the last meaningful part of the URL
// Remove trailing slashes
const cleanUrl = url.replace(/\/+$/, "")
// Common SVN patterns
const patterns = [
// Standard layout: .../repos/project/trunk -> project
/\/repos\/([^\/]+)\/(?:trunk|branches|tags)/,
// Simple repository: .../svn/project -> project
/\/svn\/([^\/]+)$/,
// Generic: last path component
/\/([^\/]+)$/,
]
for (const pattern of patterns) {
const match = cleanUrl.match(pattern)
if (match && match[1]) {
return match[1]
}
}
return ""
} catch {
return ""
}
}
/**
* Gets SVN repository information for the current VSCode workspace
* @returns SVN repository information or empty object if not available
*/
export async function getWorkspaceSvnInfo(): Promise<SvnRepositoryInfo> {
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
return {}
}
// Use the first workspace folder
const workspaceRoot = workspaceFolders[0].uri.fsPath
return getSvnRepositoryInfo(workspaceRoot)
}
async function checkSvnRepo(cwd: string): Promise<boolean> {
try {
await execAsync("svn info", { cwd })
return true
} catch (error) {
return false
}
}
/**
* Checks if SVN is installed on the system by attempting to run svn --version
* @returns {Promise<boolean>} True if SVN is installed and accessible, false otherwise
*/
export async function checkSvnInstalled(): Promise<boolean> {
try {
await execAsync("svn --version")
return true
} catch (error) {
return false
}
}
export async function searchSvnCommits(query: string, cwd: string): Promise<SvnCommit[]> {
try {
const isInstalled = await checkSvnInstalled()
if (!isInstalled) {
console.error("SVN is not installed")
return []
}
const isRepo = await checkSvnRepo(cwd)
if (!isRepo) {
console.error("Not an SVN repository")
return []
}
// Search commits by revision number or message
let command = `svn log --limit 10 --xml`
// If query looks like a revision number, search for that specific revision
if (/^r?\d+$/i.test(query)) {
const revNum = query.replace(/^r/i, "")
command = `svn log -r ${revNum} --xml`
} else if (query) {
// SVN doesn't have built-in grep for log messages, so we'll get recent logs and filter
command = `svn log --limit 50 --xml`
}
const { stdout } = await execAsync(command, { cwd })
const commits: SvnCommit[] = []
// Parse XML output
const logentries = stdout.match(/<logentry[^>]*>[\s\S]*?<\/logentry>/g) || []
for (const entry of logentries) {
const revisionMatch = entry.match(/revision="(\d+)"/)
const authorMatch = entry.match(/<author>([^<]+)<\/author>/)
const dateMatch = entry.match(/<date>([^<]+)<\/date>/)
const msgMatch = entry.match(/<msg>([^<]*)<\/msg>/)
if (revisionMatch && authorMatch && dateMatch) {
const message = msgMatch ? msgMatch[1] : ""
// If we have a search query (not revision), filter by message
if (query && !/^r?\d+$/i.test(query)) {
if (!message.toLowerCase().includes(query.toLowerCase())) {
continue
}
}
// Parse and format date
const date = new Date(dateMatch[1]).toISOString().split("T")[0]
commits.push({
revision: `r${revisionMatch[1]}`,
author: authorMatch[1],
date: date,
message: message.trim(),
})
// Limit results to 10
if (commits.length >= 10) {
break
}
}
}
return commits
} catch (error) {
console.error("Error searching SVN commits:", error)
return []
}
}
export async function getSvnCommitInfo(revision: string, cwd: string): Promise<string> {
try {
const isInstalled = await checkSvnInstalled()
if (!isInstalled) {
return "SVN is not installed"
}
const isRepo = await checkSvnRepo(cwd)
if (!isRepo) {
return "Not an SVN repository"
}
// Clean revision number (remove 'r' prefix if present)
const revNum = revision.replace(/^r/i, "")
// Get commit info with diff
const { stdout: info } = await execAsync(`svn log -r ${revNum} --verbose --xml`, { cwd })
// Parse XML output
const revisionMatch = info.match(/revision="(\d+)"/)
const authorMatch = info.match(/<author>([^<]+)<\/author>/)
const dateMatch = info.match(/<date>([^<]+)<\/date>/)
const msgMatch = info.match(/<msg>([^<]*)<\/msg>/)
if (!revisionMatch || !authorMatch || !dateMatch) {
return `Failed to get commit info for revision ${revision}`
}
const message = msgMatch ? msgMatch[1].trim() : ""
const date = new Date(dateMatch[1]).toISOString()
// Get file changes
const paths = info.match(/<path[^>]*>([^<]+)<\/path>/g) || []
const fileChanges = paths
.map((p) => {
const pathMatch = p.match(/<path[^>]*>([^<]+)<\/path>/)
const actionMatch = p.match(/action="([^"]+)"/)
if (pathMatch && actionMatch) {
return `${actionMatch[1].toUpperCase()}: ${pathMatch[1]}`
}
return pathMatch ? pathMatch[1] : ""
})
.filter(Boolean)
.join("\n")
// Get the diff
const { stdout: diff } = await execAsync(`svn diff -c ${revNum}`, { cwd })
const summary = [
`Revision: r${revisionMatch[1]}`,
`Author: ${authorMatch[1]}`,
`Date: ${date}`,
`\nMessage: ${message}`,
"\nFiles Changed:",
fileChanges,
"\nFull Changes:",
].join("\n")
const output = summary + "\n\n" + diff.trim()
return truncateOutput(output, SVN_OUTPUT_LINE_LIMIT)
} catch (error) {
console.error("Error getting SVN commit info:", error)
return `Failed to get commit info: ${error instanceof Error ? error.message : String(error)}`
}
}
export async function getSvnWorkingState(cwd: string): Promise<string> {
try {
const isInstalled = await checkSvnInstalled()
if (!isInstalled) {
return "SVN is not installed"
}
const isRepo = await checkSvnRepo(cwd)
if (!isRepo) {
return "Not an SVN repository"
}
// Get status of working directory
const { stdout: status } = await execAsync("svn status", { cwd })
if (!status.trim()) {
return "No changes in working directory"
}
// Get all changes (show diffs for modified files)
const { stdout: diff } = await execAsync("svn diff", { cwd })
const lineLimit = SVN_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 SVN working state:", error)
return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}`
}
}

View file

@ -98,6 +98,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}, [listApiConfigMeta, currentApiConfigName])
const [gitCommits, setGitCommits] = useState<any[]>([])
const [svnCommits, setSvnCommits] = useState<any[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
const [searchLoading, setSearchLoading] = useState(false)
@ -153,6 +154,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}))
setGitCommits(commits)
} else if (message.type === "svnCommitSearchResults") {
const commits = message.svnCommits.map((commit: any) => ({
type: ContextMenuOptionType.Svn,
value: commit.revision,
label: commit.message,
description: `${commit.revision} by ${commit.author} on ${commit.date}`,
icon: "$(git-commit)",
}))
setSvnCommits(commits)
} else if (message.type === "fileSearchResults") {
setSearchLoading(false)
if (message.requestId === searchRequestId) {
@ -201,6 +212,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}, [selectedType, searchQuery])
// Fetch SVN commits when SVN is selected or when typing a revision number.
useEffect(() => {
if (selectedType === ContextMenuOptionType.Svn || /^r?\d+$/i.test(searchQuery)) {
const message: WebviewMessage = {
type: "searchSvnCommits",
query: searchQuery || "",
} as const
vscode.postMessage(message)
}
}, [selectedType, searchQuery])
const handleEnhancePrompt = useCallback(() => {
if (sendingDisabled) {
return
@ -223,6 +245,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
{ type: ContextMenuOptionType.Problems, value: "problems" },
{ type: ContextMenuOptionType.Terminal, value: "terminal" },
...gitCommits,
...svnCommits,
...openedTabs
.filter((tab) => tab.path)
.map((tab) => ({
@ -237,7 +260,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
value: path,
})),
]
}, [filePaths, gitCommits, openedTabs])
}, [filePaths, gitCommits, svnCommits, openedTabs])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -276,7 +299,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (
type === ContextMenuOptionType.File ||
type === ContextMenuOptionType.Folder ||
type === ContextMenuOptionType.Git
type === ContextMenuOptionType.Git ||
type === ContextMenuOptionType.Svn
) {
if (!value) {
setSelectedType(type)
@ -302,6 +326,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
insertValue = "terminal"
} else if (type === ContextMenuOptionType.Git) {
insertValue = value || ""
} else if (type === ContextMenuOptionType.Svn) {
insertValue = value || ""
}
const { newValue, mentionIndex } = insertMention(

View file

@ -103,6 +103,7 @@ export enum ContextMenuOptionType {
Terminal = "terminal",
URL = "url",
Git = "git",
Svn = "svn",
NoResults = "noResults",
Mode = "mode", // Add mode type
}
@ -165,6 +166,14 @@ export function getContextMenuOptions(
icon: "$(git-commit)",
}
const svnWorkingChanges: ContextMenuQueryItem = {
type: ContextMenuOptionType.Svn,
value: "svn-changes",
label: "SVN Working changes",
description: "Current uncommitted SVN changes",
icon: "$(git-commit)",
}
if (query === "") {
if (selectedType === ContextMenuOptionType.File) {
const files = queryItems
@ -191,6 +200,11 @@ export function getContextMenuOptions(
return commits.length > 0 ? [workingChanges, ...commits] : [workingChanges]
}
if (selectedType === ContextMenuOptionType.Svn) {
const commits = queryItems.filter((item) => item.type === ContextMenuOptionType.Svn)
return commits.length > 0 ? [svnWorkingChanges, ...commits] : [svnWorkingChanges]
}
return [
{ type: ContextMenuOptionType.Problems },
{ type: ContextMenuOptionType.Terminal },
@ -198,6 +212,7 @@ export function getContextMenuOptions(
{ type: ContextMenuOptionType.Folder },
{ type: ContextMenuOptionType.File },
{ type: ContextMenuOptionType.Git },
{ type: ContextMenuOptionType.Svn },
]
}
@ -214,6 +229,16 @@ export function getContextMenuOptions(
})
} else if ("git-changes".startsWith(lowerQuery)) {
suggestions.push(workingChanges)
} else if ("svn-changes".startsWith(lowerQuery)) {
suggestions.push(svnWorkingChanges)
}
if ("svn".startsWith(lowerQuery)) {
suggestions.push({
type: ContextMenuOptionType.Svn,
label: "SVN Commits",
description: "Search SVN repository history",
icon: "$(git-commit)",
})
}
if ("problems".startsWith(lowerQuery)) {
suggestions.push({ type: ContextMenuOptionType.Problems })
@ -244,6 +269,25 @@ export function getContextMenuOptions(
}
}
// Add exact SVN revision matches to suggestions
if (/^r?\d+$/i.test(lowerQuery)) {
const exactMatches = queryItems.filter(
(item) => item.type === ContextMenuOptionType.Svn && item.value?.toLowerCase() === lowerQuery,
)
if (exactMatches.length > 0) {
suggestions.push(...exactMatches)
} else {
// If no exact match but valid revision format, add as option
suggestions.push({
type: ContextMenuOptionType.Svn,
value: lowerQuery,
label: `Revision ${lowerQuery}`,
description: "SVN revision number",
icon: "$(git-commit)",
})
}
}
const searchableItems = queryItems.map((item) => ({
original: item,
searchStr: [item.value, item.label, item.description].filter(Boolean).join(" "),
@ -261,6 +305,7 @@ export function getContextMenuOptions(
const openedFileMatches = matchingItems.filter((item) => item.type === ContextMenuOptionType.OpenedFile)
const gitMatches = matchingItems.filter((item) => item.type === ContextMenuOptionType.Git)
const svnMatches = matchingItems.filter((item) => item.type === ContextMenuOptionType.Svn)
// Convert search results to queryItems format
const searchResultItems = dynamicSearchResults.map((result) => {
@ -282,7 +327,7 @@ export function getContextMenuOptions(
}
})
const allItems = [...suggestions, ...openedFileMatches, ...searchResultItems, ...gitMatches]
const allItems = [...suggestions, ...openedFileMatches, ...searchResultItems, ...gitMatches, ...svnMatches]
// Remove duplicates - normalize paths by ensuring all have leading slashes
const seen = new Set()