Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/apiKeyManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { type ExtensionContext, window } from "vscode"

export class APIKeyManager {
constructor(private context: ExtensionContext) {}

async setAPIKey(): Promise<string | undefined> {
try {
const apiKey = await window.showInputBox({
prompt: "Enter your Anthropic API Key",
password: true,
placeHolder: "sk-ant-api...",
})

if (!apiKey) {
window.showErrorMessage("API Key is required")
return undefined
}

if (!apiKey.startsWith("sk-ant-api")) {
window.showErrorMessage("Invalid Anthropic API Key format. Should start with sk-ant-api")
return undefined
}

await this.context.secrets.store("anthropic-api-key", apiKey)
window.showInformationMessage("API Key updated successfully")

return apiKey
} catch (error) {
console.error("Secrets storage error:", error)
window.showErrorMessage(
`Failed to update API key in secure storage: ${error instanceof Error ? error.message : String(error)}`,
)
return undefined
}
}

async getAPIKey(): Promise<string | undefined> {
try {
return await this.context.secrets.get("anthropic-api-key")
} catch (error) {
console.error("Secrets storage error:", error)
window.showErrorMessage(
`Failed to access secure storage: ${error instanceof Error ? error.message : String(error)}`,
)
return undefined
}
}

async deleteAPIKey(): Promise<void> {
try {
const apiKey = await this.context.secrets.get("anthropic-api-key")
if (!apiKey) {
window.showWarningMessage("No API Key found to remove")
return
}
await this.context.secrets.delete("anthropic-api-key")
window.showInformationMessage("API Key deleted successfully")
} catch (error) {
console.error("Secrets storage error:", error)
window.showErrorMessage(
`Failed to delete API key from secure storage: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}
82 changes: 82 additions & 0 deletions src/cmdPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as vscode from "vscode"

export function activate(context: vscode.ExtensionContext) {
// Keep track of document content in memory
const documentContent = new Map<string, string>()
const originalContent = new Map<string, string>()

// Create event emitter for content changes
const onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>()

// Register the content provider
const provider = {
onDidChange: onDidChangeEmitter.event,
provideTextDocumentContent: (uri: vscode.Uri) => documentContent.get(uri.path) || "",
}

const registration = vscode.workspace.registerTextDocumentContentProvider("commit-preview", provider)

const disposable = vscode.commands.registerCommand(
"extension.previewCommitMessage",
async (aiGeneratedMessage: string) => {
// Create URI for this preview
const uri = vscode.Uri.parse("commit-preview:Commit Message Preview")

// Store initial content
documentContent.set(uri.path, aiGeneratedMessage)
originalContent.set(uri.path, aiGeneratedMessage)

// Open the document
const doc = await vscode.workspace.openTextDocument(uri)
await vscode.window.showTextDocument(doc, {
preview: true,
preserveFocus: false,
})

// Handle saves
const saveListener = vscode.workspace.onDidSaveTextDocument((savedDoc) => {
if (savedDoc === doc) {
const content = documentContent.get(uri.path)
if (content) {
vscode.scm.inputBox.value = content
vscode.window.setStatusBarMessage("Commit message updated", 2000)
}
}
})

// Handle document changes
const changeListener = vscode.workspace.onDidChangeTextDocument((e) => {
if (e.document === doc) {
documentContent.set(uri.path, e.document.getText())
onDidChangeEmitter.fire(uri)
}
})

// Handle closing
const closeListener = vscode.workspace.onDidCloseTextDocument((closedDoc) => {
if (closedDoc === doc) {
const current = documentContent.get(uri.path)
const original = originalContent.get(uri.path)

// Only update SCM if content was modified
if (current && original && current !== original) {
vscode.scm.inputBox.value = current
}

// Cleanup
documentContent.delete(uri.path)
originalContent.delete(uri.path)
saveListener.dispose()
changeListener.dispose()
closeListener.dispose()
}
})

context.subscriptions.push(saveListener, changeListener, closeListener)
},
)

context.subscriptions.push(disposable, registration)
}

export function deactivate() {}
104 changes: 104 additions & 0 deletions src/commitMessageGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Anthropic from "@anthropic-ai/sdk"
import { window } from "vscode"
import type { CommitConfig } from "./configManager"

export class CommitMessageGenerator {
constructor(private apiKey: string) {}

async generateMessage(diff: string, config: CommitConfig): Promise<string | undefined> {
const anthropic = new Anthropic({
apiKey: this.apiKey,
})

const systemPrompt =
"You are a seasoned software developer with an extraordinary ability for writing detailed conventional commit messages and following 'instructions' and 'customInstructions' when generating them."

const prompt = `
<task>
Generate a detailed conventional commit message for the following Git diff:

${diff}
</task>
<instructions>
- Use ONLY ${config.allowedTypes.map((val) => `'${val}'`).join(" | ")} as appropriate for the type of change.
- Always include a scope.
- Never use '!' or 'BREAKING CHANGE' in the commit message.
- Output will use markdown formatting for lists etc.
- Output will ONLY contain the commit message.
- Do not explain the output.
</instructions>
${config.customInstructions ? `<customInstructions>\n${config.customInstructions}\n</customInstructions>` : ""}
`.trim()

let message: Anthropic.Message | undefined = undefined
try {
message = await anthropic.messages.create({
model: config.model,
max_tokens: config.maxTokens,
temperature: config.temperature,
system: systemPrompt,
messages: [
{
role: "user",
content: prompt,
},
],
})

let commitMessage = message.content
.filter((msg) => msg.type === "text" && "text" in msg)
.map((msg) => msg.text)
.join("\n")
.replace(/\n{3,}/g, "\n\n") // Replace 3 or more newlines with 2 newlines
.replace(/(?<![\\\w])\*+[ \t]+/g, "- ") // Replace bullets occasionally output by the model with hyphens
.trim()

if (!commitMessage) {
window.showWarningMessage("No commit message was generated")
return undefined
}

// Replace bullets occasionally output by the model with hyphens
return commitMessage
} catch (error) {
this.handleError(error)
return undefined
} finally {
console.log("[DiffCommit] Stop Reason: ", message?.stop_reason)
console.log("[DiffCommit] Usage: ", message?.usage)
}
}

private handleError(error: unknown): void {
if (error instanceof Anthropic.APIError) {
const errorMessage = error.message || "Unknown Anthropic API error"
console.error(`Anthropic API Error (${error.status}):\n\n${errorMessage}`)

switch (error.status) {
case 400:
window.showErrorMessage("Bad request. Review your prompt and try again.")
break
case 401:
window.showErrorMessage("Invalid API key. Please update your API key and try again.")
break
case 403:
window.showErrorMessage("Permission Denied. Review your prompt or API key and try again.")
break
case 429:
window.showErrorMessage(`Rate limit exceeded. Please try again later:\n\n${errorMessage}`)
break
case 500:
window.showErrorMessage("Anthropic API server error. Please try again later.")
break
default:
window.showErrorMessage(`Failed to generate commit message:\n\n${errorMessage}`)
break
}
} else {
console.error(`Unknown error: ${error instanceof Error ? error.message : String(error)}`)
window.showErrorMessage(
`Unknown error generating commit message: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}
38 changes: 38 additions & 0 deletions src/configManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { workspace } from "vscode"

export interface CommitConfig {
allowedTypes: string[]
customInstructions?: string
maxTokens: number
model: string
temperature: number
}

export class ConfigManager {
private static readonly defaultAllowedTypes = [
"feat",
"fix",
"refactor",
"chore",
"docs",
"style",
"test",
"perf",
"ci",
]
private static readonly defaultMaxTokens = 1024
private static readonly defaultModel = "claude-3-5-sonnet-latest"
private static readonly defaultTemperature = 0.4

getConfig(): CommitConfig {
const config = workspace.getConfiguration("diffCommit")

return {
allowedTypes: config.get<string[]>("allowedTypes") || ConfigManager.defaultAllowedTypes,
customInstructions: config.get<string>("customInstructions"),
maxTokens: config.get<number>("maxTokens") || ConfigManager.defaultMaxTokens,
model: config.get<string>("model") || ConfigManager.defaultModel,
temperature: config.get<number>("temperature") || ConfigManager.defaultTemperature,
}
}
}
Loading