From f891aef3b6b94bedbfa78d74cfd806bf0ae17190 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 23 Jul 2025 09:24:05 +0000 Subject: [PATCH] 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 --- src/core/mentions/index.ts | 19 ++ src/core/webview/webviewMessageHandler.ts | 19 ++ src/shared/ExtensionMessage.ts | 3 + src/shared/WebviewMessage.ts | 1 + src/shared/context-mentions.ts | 34 +- src/utils/__tests__/svn.test.ts | 322 ++++++++++++++++++ src/utils/svn.ts | 317 +++++++++++++++++ .../src/components/chat/ChatTextArea.tsx | 30 +- webview-ui/src/utils/context-mentions.ts | 47 ++- 9 files changed, 786 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/svn.test.ts create mode 100644 src/utils/svn.ts diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index 780b27d1f70..eca0f666159 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 780d40df891..ae6ebb7888d 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 4f2aa2da159..0d0d9ecc53c 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 1f56829f7b3..09918ede7ee 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 2edb99de6ad..225e2b2af7b 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 00000000000..67ca035d678 --- /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 6c541353eb2..aaf6ca33e19 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 889dca9dbea..881d3e3ce75 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()