mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
9956cc1f48
commit
f891aef3b6
9 changed files with 786 additions and 6 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export interface WebviewMessage {
|
|||
| "mcpEnabled"
|
||||
| "enableMcpServerCreation"
|
||||
| "searchCommits"
|
||||
| "searchSvnCommits"
|
||||
| "alwaysApproveResubmit"
|
||||
| "requestDelaySeconds"
|
||||
| "setApiConfigPassword"
|
||||
|
|
|
|||
|
|
@ -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, " ")
|
||||
|
|
|
|||
322
src/utils/__tests__/svn.test.ts
Normal file
322
src/utils/__tests__/svn.test.ts
Normal 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
317
src/utils/svn.ts
Normal 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)}`
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue