diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts
index 780b27d1f7..eca0f66615 100644
--- a/src/core/mentions/index.ts
+++ b/src/core/mentions/index.ts
@@ -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\nError fetching working state: ${error.message}\n`
}
+ } else if (mention === "svn-changes") {
+ try {
+ const workingState = await getSvnWorkingState(cwd)
+ parsedText += `\n\n\n${workingState}\n`
+ } catch (error) {
+ parsedText += `\n\n\nError fetching SVN working state: ${error.message}\n`
+ }
} 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\nError fetching commit info: ${error.message}\n`
}
+ } else if (/^r?\d+$/.test(mention)) {
+ try {
+ const commitInfo = await getSvnCommitInfo(mention, cwd)
+ parsedText += `\n\n\n${commitInfo}\n`
+ } catch (error) {
+ parsedText += `\n\n\nError fetching SVN revision info: ${error.message}\n`
+ }
} else if (mention === "terminal") {
try {
const terminalOutput = await getLatestTerminalOutput()
diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts
index 780d40df89..ae6ebb7888 100644
--- a/src/core/webview/webviewMessageHandler.ts
+++ b/src/core/webview/webviewMessageHandler.ts
@@ -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()
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index 4f2aa2da15..0d0d9ecc53 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/src/shared/ExtensionMessage.ts
@@ -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
diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts
index 1f56829f7b..09918ede7e 100644
--- a/src/shared/WebviewMessage.ts
+++ b/src/shared/WebviewMessage.ts
@@ -129,6 +129,7 @@ export interface WebviewMessage {
| "mcpEnabled"
| "enableMcpServerCreation"
| "searchCommits"
+ | "searchSvnCommits"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "setApiConfigPassword"
diff --git a/src/shared/context-mentions.ts b/src/shared/context-mentions.ts
index 2edb99de6a..225e2b2af7 100644
--- a/src/shared/context-mentions.ts
+++ b/src/shared/context-mentions.ts
@@ -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 =
- /(? ({
+ 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 = `
+
+
+john
+2024-01-15T10:30:00.000000Z
+Fix bug in login
+
+
+jane
+2024-01-14T15:45:00.000000Z
+Add new feature
+
+`
+ 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 = `
+
+
+john
+2024-01-15T10:30:00.000000Z
+Fix bug in login
+
+`
+ 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 = `
+
+
+john
+2024-01-15T10:30:00.000000Z
+Fix bug in login
+
+/trunk/src/login.js
+
+
+`
+ 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 = `
+
+
+https://svn.example.com/repos/myproject/trunk
+
+https://svn.example.com/repos/myproject
+
+
+
+
+`
+ 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({})
+ })
+ })
+})
diff --git a/src/utils/svn.ts b/src/utils/svn.ts
new file mode 100644
index 0000000000..67ca035d67
--- /dev/null
+++ b/src/utils/svn.ts
@@ -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 {
+ 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>/)
+ const rootMatch = stdout.match(/([^<]+)<\/root>/)
+ const revisionMatch = stdout.match(/]*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 {
+ 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 {
+ 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} True if SVN is installed and accessible, false otherwise
+ */
+export async function checkSvnInstalled(): Promise {
+ try {
+ await execAsync("svn --version")
+ return true
+ } catch (error) {
+ return false
+ }
+}
+
+export async function searchSvnCommits(query: string, cwd: string): Promise {
+ 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(/]*>[\s\S]*?<\/logentry>/g) || []
+
+ for (const entry of logentries) {
+ const revisionMatch = entry.match(/revision="(\d+)"/)
+ const authorMatch = entry.match(/([^<]+)<\/author>/)
+ const dateMatch = entry.match(/([^<]+)<\/date>/)
+ const msgMatch = entry.match(/([^<]*)<\/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 {
+ 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>/)
+ const dateMatch = info.match(/([^<]+)<\/date>/)
+ const msgMatch = info.match(/([^<]*)<\/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>/g) || []
+ const fileChanges = paths
+ .map((p) => {
+ const pathMatch = p.match(/]*>([^<]+)<\/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 {
+ 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)}`
+ }
+}
diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx
index 6c541353eb..aaf6ca33e1 100644
--- a/webview-ui/src/components/chat/ChatTextArea.tsx
+++ b/webview-ui/src/components/chat/ChatTextArea.tsx
@@ -98,6 +98,7 @@ const ChatTextArea = forwardRef(
}, [listApiConfigMeta, currentApiConfigName])
const [gitCommits, setGitCommits] = useState([])
+ const [svnCommits, setSvnCommits] = useState([])
const [showDropdown, setShowDropdown] = useState(false)
const [fileSearchResults, setFileSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false)
@@ -153,6 +154,16 @@ const ChatTextArea = forwardRef(
}))
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(
}
}, [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(
{ 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(
value: path,
})),
]
- }, [filePaths, gitCommits, openedTabs])
+ }, [filePaths, gitCommits, svnCommits, openedTabs])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -276,7 +299,8 @@ const ChatTextArea = forwardRef(
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(
insertValue = "terminal"
} else if (type === ContextMenuOptionType.Git) {
insertValue = value || ""
+ } else if (type === ContextMenuOptionType.Svn) {
+ insertValue = value || ""
}
const { newValue, mentionIndex } = insertMention(
diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts
index 889dca9dbe..881d3e3ce7 100644
--- a/webview-ui/src/utils/context-mentions.ts
+++ b/webview-ui/src/utils/context-mentions.ts
@@ -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()