Skip to content

Commit 531b971

Browse files
authored
merge(abstraction): abstract extension to components (#21)
- Add new preview command abstraction (not yet implemented) - Abstract single file extension.ts to class-based components to separate growing concerns - Add git extension type to git.d.ts from [VSCode API repo](https://github.com/microsoft/vscode/blob/main/extensions/git/src/api/git.d.ts) - Refactor all new files: - Replace wildcard import with specific named imports - Add explicit type imports for ExtensionContext and TextDocument etc - Improve error message formatting with newlines for better readability - Remove redundant string concatenation in error logging - Update all tests to accomodate the updated formatting of errors
1 parent 1b00a71 commit 531b971

10 files changed

Lines changed: 793 additions & 233 deletions

src/apiKeyManager.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { type ExtensionContext, window } from "vscode"
2+
3+
export class APIKeyManager {
4+
constructor(private context: ExtensionContext) {}
5+
6+
async setAPIKey(): Promise<string | undefined> {
7+
try {
8+
const apiKey = await window.showInputBox({
9+
prompt: "Enter your Anthropic API Key",
10+
password: true,
11+
placeHolder: "sk-ant-api...",
12+
})
13+
14+
if (!apiKey) {
15+
window.showErrorMessage("API Key is required")
16+
return undefined
17+
}
18+
19+
if (!apiKey.startsWith("sk-ant-api")) {
20+
window.showErrorMessage("Invalid Anthropic API Key format. Should start with sk-ant-api")
21+
return undefined
22+
}
23+
24+
await this.context.secrets.store("anthropic-api-key", apiKey)
25+
window.showInformationMessage("API Key updated successfully")
26+
27+
return apiKey
28+
} catch (error) {
29+
console.error("Secrets storage error:", error)
30+
window.showErrorMessage(
31+
`Failed to update API key in secure storage: ${error instanceof Error ? error.message : String(error)}`,
32+
)
33+
return undefined
34+
}
35+
}
36+
37+
async getAPIKey(): Promise<string | undefined> {
38+
try {
39+
return await this.context.secrets.get("anthropic-api-key")
40+
} catch (error) {
41+
console.error("Secrets storage error:", error)
42+
window.showErrorMessage(
43+
`Failed to access secure storage: ${error instanceof Error ? error.message : String(error)}`,
44+
)
45+
return undefined
46+
}
47+
}
48+
49+
async deleteAPIKey(): Promise<void> {
50+
try {
51+
const apiKey = await this.context.secrets.get("anthropic-api-key")
52+
if (!apiKey) {
53+
window.showWarningMessage("No API Key found to remove")
54+
return
55+
}
56+
await this.context.secrets.delete("anthropic-api-key")
57+
window.showInformationMessage("API Key deleted successfully")
58+
} catch (error) {
59+
console.error("Secrets storage error:", error)
60+
window.showErrorMessage(
61+
`Failed to delete API key from secure storage: ${error instanceof Error ? error.message : String(error)}`,
62+
)
63+
}
64+
}
65+
}

src/cmdPreview.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import * as vscode from "vscode"
2+
3+
export function activate(context: vscode.ExtensionContext) {
4+
// Keep track of document content in memory
5+
const documentContent = new Map<string, string>()
6+
const originalContent = new Map<string, string>()
7+
8+
// Create event emitter for content changes
9+
const onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>()
10+
11+
// Register the content provider
12+
const provider = {
13+
onDidChange: onDidChangeEmitter.event,
14+
provideTextDocumentContent: (uri: vscode.Uri) => documentContent.get(uri.path) || "",
15+
}
16+
17+
const registration = vscode.workspace.registerTextDocumentContentProvider("commit-preview", provider)
18+
19+
const disposable = vscode.commands.registerCommand(
20+
"extension.previewCommitMessage",
21+
async (aiGeneratedMessage: string) => {
22+
// Create URI for this preview
23+
const uri = vscode.Uri.parse("commit-preview:Commit Message Preview")
24+
25+
// Store initial content
26+
documentContent.set(uri.path, aiGeneratedMessage)
27+
originalContent.set(uri.path, aiGeneratedMessage)
28+
29+
// Open the document
30+
const doc = await vscode.workspace.openTextDocument(uri)
31+
await vscode.window.showTextDocument(doc, {
32+
preview: true,
33+
preserveFocus: false,
34+
})
35+
36+
// Handle saves
37+
const saveListener = vscode.workspace.onDidSaveTextDocument((savedDoc) => {
38+
if (savedDoc === doc) {
39+
const content = documentContent.get(uri.path)
40+
if (content) {
41+
vscode.scm.inputBox.value = content
42+
vscode.window.setStatusBarMessage("Commit message updated", 2000)
43+
}
44+
}
45+
})
46+
47+
// Handle document changes
48+
const changeListener = vscode.workspace.onDidChangeTextDocument((e) => {
49+
if (e.document === doc) {
50+
documentContent.set(uri.path, e.document.getText())
51+
onDidChangeEmitter.fire(uri)
52+
}
53+
})
54+
55+
// Handle closing
56+
const closeListener = vscode.workspace.onDidCloseTextDocument((closedDoc) => {
57+
if (closedDoc === doc) {
58+
const current = documentContent.get(uri.path)
59+
const original = originalContent.get(uri.path)
60+
61+
// Only update SCM if content was modified
62+
if (current && original && current !== original) {
63+
vscode.scm.inputBox.value = current
64+
}
65+
66+
// Cleanup
67+
documentContent.delete(uri.path)
68+
originalContent.delete(uri.path)
69+
saveListener.dispose()
70+
changeListener.dispose()
71+
closeListener.dispose()
72+
}
73+
})
74+
75+
context.subscriptions.push(saveListener, changeListener, closeListener)
76+
},
77+
)
78+
79+
context.subscriptions.push(disposable, registration)
80+
}
81+
82+
export function deactivate() {}

src/commitMessageGenerator.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import Anthropic from "@anthropic-ai/sdk"
2+
import { window } from "vscode"
3+
import type { CommitConfig } from "./configManager"
4+
5+
export class CommitMessageGenerator {
6+
constructor(private apiKey: string) {}
7+
8+
async generateMessage(diff: string, config: CommitConfig): Promise<string | undefined> {
9+
const anthropic = new Anthropic({
10+
apiKey: this.apiKey,
11+
})
12+
13+
const systemPrompt =
14+
"You are a seasoned software developer with an extraordinary ability for writing detailed conventional commit messages and following 'instructions' and 'customInstructions' when generating them."
15+
16+
const prompt = `
17+
<task>
18+
Generate a detailed conventional commit message for the following Git diff:
19+
20+
${diff}
21+
</task>
22+
<instructions>
23+
- Use ONLY ${config.allowedTypes.map((val) => `'${val}'`).join(" | ")} as appropriate for the type of change.
24+
- Always include a scope.
25+
- Never use '!' or 'BREAKING CHANGE' in the commit message.
26+
- Output will use markdown formatting for lists etc.
27+
- Output will ONLY contain the commit message.
28+
- Do not explain the output.
29+
</instructions>
30+
${config.customInstructions ? `<customInstructions>\n${config.customInstructions}\n</customInstructions>` : ""}
31+
`.trim()
32+
33+
let message: Anthropic.Message | undefined = undefined
34+
try {
35+
message = await anthropic.messages.create({
36+
model: config.model,
37+
max_tokens: config.maxTokens,
38+
temperature: config.temperature,
39+
system: systemPrompt,
40+
messages: [
41+
{
42+
role: "user",
43+
content: prompt,
44+
},
45+
],
46+
})
47+
48+
let commitMessage = message.content
49+
.filter((msg) => msg.type === "text" && "text" in msg)
50+
.map((msg) => msg.text)
51+
.join("\n")
52+
.replace(/\n{3,}/g, "\n\n") // Replace 3 or more newlines with 2 newlines
53+
.replace(/(?<![\\\w])\*+[ \t]+/g, "- ") // Replace bullets occasionally output by the model with hyphens
54+
.trim()
55+
56+
if (!commitMessage) {
57+
window.showWarningMessage("No commit message was generated")
58+
return undefined
59+
}
60+
61+
// Replace bullets occasionally output by the model with hyphens
62+
return commitMessage
63+
} catch (error) {
64+
this.handleError(error)
65+
return undefined
66+
} finally {
67+
console.log("[DiffCommit] Stop Reason: ", message?.stop_reason)
68+
console.log("[DiffCommit] Usage: ", message?.usage)
69+
}
70+
}
71+
72+
private handleError(error: unknown): void {
73+
if (error instanceof Anthropic.APIError) {
74+
const errorMessage = error.message || "Unknown Anthropic API error"
75+
console.error(`Anthropic API Error (${error.status}):\n\n${errorMessage}`)
76+
77+
switch (error.status) {
78+
case 400:
79+
window.showErrorMessage("Bad request. Review your prompt and try again.")
80+
break
81+
case 401:
82+
window.showErrorMessage("Invalid API key. Please update your API key and try again.")
83+
break
84+
case 403:
85+
window.showErrorMessage("Permission Denied. Review your prompt or API key and try again.")
86+
break
87+
case 429:
88+
window.showErrorMessage(`Rate limit exceeded. Please try again later:\n\n${errorMessage}`)
89+
break
90+
case 500:
91+
window.showErrorMessage("Anthropic API server error. Please try again later.")
92+
break
93+
default:
94+
window.showErrorMessage(`Failed to generate commit message:\n\n${errorMessage}`)
95+
break
96+
}
97+
} else {
98+
console.error(`Unknown error: ${error instanceof Error ? error.message : String(error)}`)
99+
window.showErrorMessage(
100+
`Unknown error generating commit message: ${error instanceof Error ? error.message : String(error)}`,
101+
)
102+
}
103+
}
104+
}

src/configManager.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { workspace } from "vscode"
2+
3+
export interface CommitConfig {
4+
allowedTypes: string[]
5+
customInstructions?: string
6+
maxTokens: number
7+
model: string
8+
temperature: number
9+
}
10+
11+
export class ConfigManager {
12+
private static readonly defaultAllowedTypes = [
13+
"feat",
14+
"fix",
15+
"refactor",
16+
"chore",
17+
"docs",
18+
"style",
19+
"test",
20+
"perf",
21+
"ci",
22+
]
23+
private static readonly defaultMaxTokens = 1024
24+
private static readonly defaultModel = "claude-3-5-sonnet-latest"
25+
private static readonly defaultTemperature = 0.4
26+
27+
getConfig(): CommitConfig {
28+
const config = workspace.getConfiguration("diffCommit")
29+
30+
return {
31+
allowedTypes: config.get<string[]>("allowedTypes") || ConfigManager.defaultAllowedTypes,
32+
customInstructions: config.get<string>("customInstructions"),
33+
maxTokens: config.get<number>("maxTokens") || ConfigManager.defaultMaxTokens,
34+
model: config.get<string>("model") || ConfigManager.defaultModel,
35+
temperature: config.get<number>("temperature") || ConfigManager.defaultTemperature,
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)