Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

## ✨ 功能特性

- **一键生成** — 在 Commit 对话框工具栏中点击闪电按钮,基于已选中的文件变更自动生成 commit message
- **一键生成** — 在 Commit 工具栏或 Git Log 的 Edit Commit Message 弹窗中点击“闪电按钮”,基于对应变更自动生成 commit message
- **流式输出** — 实时显示生成过程,无需等待完整响应
- **多 AI 提供商** — 支持 OpenAI、Anthropic、Gemini 及任意 OpenAI 兼容端点
- **提示词模板** — 内置 Conventional Commits / Simple / Detailed 三套模板,支持自定义
Expand Down
9 changes: 8 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType

plugins {
id("org.jetbrains.kotlin.jvm") version "2.1.0"
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0"
id("org.jetbrains.intellij.platform") version "2.2.1"
}

group = "com.github.fangzc"
version = "1.1.2"
version = "1.1.5"

// 正式编译始终以 2024.3(build 243)为最低兼容基线。
// 该可选参数只为 runIdeLocal 指定额外的本机运行时,不会改变 compileClasspath。
val localIdePath = providers.gradleProperty("localIdePath")

kotlin {
jvmToolchain(21)
Expand All @@ -20,6 +26,7 @@ repositories {

dependencies {
intellijPlatform {
// 使用最低支持版本编译,避免无意引用 261 新增 API;plugin.xml 不设置 until-build。
intellijIdeaCommunity("2024.3")
bundledPlugin("Git4Idea")
pluginVerifier()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,83 @@ import com.github.fangzc.aicommit.prompt.PromptBuilder
import com.github.fangzc.aicommit.settings.PluginSettings
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.progress.currentThreadCoroutineScope
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.vcs.VcsDataKeys
import com.intellij.openapi.vcs.changes.Change
import com.intellij.openapi.vcs.ui.CommitMessage
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.vcs.commit.AbstractCommitWorkflowHandler
import com.intellij.vcs.log.VcsLogCommitSelection
import com.intellij.vcs.log.VcsLogDataKeys
import kotlinx.coroutines.*
import kotlin.coroutines.resume

private const val LOG_DETAILS_TIMEOUT_MS = 30_000L

/**
* 主按钮 Action:在 commit message 编辑器工具栏中的 AI 生成按钮
* 注册到 Vcs.MessageActionGroup,出现在 commit message 编辑器上方
* 主按钮 Action:在 commit message 编辑器工具栏中的 AI 生成按钮
* 注册到 Vcs.MessageActionGroup,同时支持普通提交窗口和 Git Log 的 Edit Commit Message 弹窗。
*/
class GenerateCommitMessageAction : AnAction() {

// 当前正在执行的生成任务,用于支持取消
@Volatile
private var currentJob: Job? = null

init {
templatePresentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true)
}

override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT

override fun update(e: AnActionEvent) {
// 只在 commit 上下文中显示按钮
val workflowHandler = e.getData(VcsDataKeys.COMMIT_WORKFLOW_HANDLER)
e.presentation.isEnabledAndVisible = workflowHandler != null
val project = e.project
val document = e.getData(VcsDataKeys.COMMIT_MESSAGE_DOCUMENT)
val workflowUi = e.getData(VcsDataKeys.COMMIT_WORKFLOW_UI)
val logSelection = e.getData(VcsLogDataKeys.VCS_LOG_COMMIT_SELECTION)
val changesSupplier = document?.getUserData(CommitMessage.CHANGES_SUPPLIER_KEY)

/*
* CommitMessage 工具栏在不同 IDE/入口中提供的数据不同:
* 1. 普通 Commit 窗口:COMMIT_WORKFLOW_UI;
* 2. IDEA 2024.3 Git Log Reword 弹窗:VCS_LOG_COMMIT_SELECTION;
* 3. 原 Action DataContext 含非空 SELECTED_CHANGES_IN_DETAILS:Document 上的 CHANGES_SUPPLIER_KEY;
* 4. IDEA 2026.1 普通 Git Log Reword 弹窗:监听内置 Action 后桥接到 Document 的 selection。
*
* 原生 DataContext/Supplier 比桥接数据更直接,因此出现时清掉尚未消费的 pending context。
*/
val effectiveLogSelection = when {
logSelection != null -> {
project?.let(RewordCommitContextBridge::clearPending)
logSelection
}
workflowUi != null || changesSupplier != null -> {
project?.let(RewordCommitContextBridge::clearPending)
null
}
document != null && project != null ->
RewordCommitContextBridge.findOrBind(project, document)
else -> null
}

// 只有确认能取得提交消息 Document,并且至少存在一种变更来源时才展示按钮。
// 这样不会在仅复用 CommitMessage 组件、但没有可生成 diff 的其他弹窗里显示无效入口。
e.presentation.isEnabledAndVisible = document != null &&
(workflowUi != null || changesSupplier != null ||
effectiveLogSelection?.commits?.isNotEmpty() == true)
}

override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val document = e.getData(VcsDataKeys.COMMIT_MESSAGE_DOCUMENT) ?: return

// 获取 commit workflow handler 以获取已选中的变更
val workflowHandler = e.getData(VcsDataKeys.COMMIT_WORKFLOW_HANDLER)
as? AbstractCommitWorkflowHandler<*, *>
if (workflowHandler == null) {
showNotification(project, "Cannot access commit workflow.", NotificationType.ERROR)
return
}
val messageDisposable = e.getData(VcsDataKeys.COMMIT_MESSAGE_CONTROL) as? Disposable

// 如果正在生成中,取消当前任务
currentJob?.let {
Expand All @@ -57,16 +94,34 @@ class GenerateCommitMessageAction : AnAction() {
}
}

// 获取已选中的变更
val includedChanges: List<Change> = workflowHandler.ui.getIncludedChanges()
if (includedChanges.isEmpty()) {
showNotification(
project,
"No files selected for commit. Please select files first.",
NotificationType.WARNING
)
// actionPerformed 必须重新解析数据,不能依赖 update 时的 AnActionEvent/DataContext 快照。
val workflowUi = e.getData(VcsDataKeys.COMMIT_WORKFLOW_UI)
val logSelection = e.getData(VcsLogDataKeys.VCS_LOG_COMMIT_SELECTION)

// 原 Action DataContext 含非空 SELECTED_CHANGES_IN_DETAILS 时,IDE 会把对应 changes Supplier
// 存到 Document userData;普通 Git Log 行右键不满足此条件。
val suppliedChanges = document.getUserData(CommitMessage.CHANGES_SUPPLIER_KEY)
?.get()
?.toList()

// 普通 261 Git Log 右键没有 Supplier,只能读取 update 阶段已绑定或刚捕获的桥接 selection。
val effectiveLogSelection = logSelection ?: if (workflowUi == null && suppliedChanges == null) {
RewordCommitContextBridge.findOrBind(project, document)
} else {
null
}

// 已有平台原生数据时清理 pending,避免它在后续无关弹窗中被消费。
if (logSelection != null || suppliedChanges != null) {
RewordCommitContextBridge.clearPending(project)
}

// 三种来源均不存在时无法构造 diff,直接给出明确错误而不是发起空 AI 请求。
if (workflowUi == null && suppliedChanges == null && effectiveLogSelection == null) {
showNotification(project, "Cannot access commit changes.", NotificationType.ERROR)
return
}
val workflowChanges = workflowUi?.getIncludedChanges()

// 检查 API Key 是否已配置
val settings = PluginSettings.getInstance()
Expand All @@ -79,12 +134,42 @@ class GenerateCommitMessageAction : AnAction() {
return
}

// 任务随 commit message 控件销毁,关闭弹窗后不再继续请求或回写
val generationDisposable = if (messageDisposable != null) {
val child = Disposer.newDisposable("AI commit message generation")
if (!Disposer.tryRegister(messageDisposable, child)) {
Disposer.dispose(child)
return
}
child
} else {
null
}

// 启动后台任务
@Suppress("UnstableApiUsage")
currentJob = CoroutineScope(Dispatchers.Default + SupervisorJob()).launch {
val generationJob = currentThreadCoroutineScope().launch(
context = Dispatchers.Default,
start = CoroutineStart.LAZY
) {
withBackgroundProgress(project, "Generating commit message...") {
try {
// 1. 收集 diff
// 数据源优先级:当前 Commit 勾选项 > 弹窗直接提供的 changes > Git Log selection 完整详情。
// 保留该顺序可确保普通 Commit 不会误用之前缓存的历史提交。
val includedChanges = workflowChanges
?: suppliedChanges
?: effectiveLogSelection?.let { loadChanges(it) }
?: emptyList()
if (includedChanges.isEmpty()) {
showNotification(
project,
"No files selected for commit. Please select files first.",
NotificationType.WARNING
)
return@withBackgroundProgress
}

// 1. 收集普通提交的已选变更,或历史提交相对父提交的变更
val diff = withContext(Dispatchers.IO) {
DiffCollector.computeDiff(
changes = includedChanges,
Expand Down Expand Up @@ -121,6 +206,7 @@ class GenerateCommitMessageAction : AnAction() {
// 每次收到 token,在 EDT 线程更新 commit message
com.intellij.openapi.application.ApplicationManager.getApplication()
.invokeLater {
if (isTargetDisposed(project, messageDisposable)) return@invokeLater
WriteCommandAction.runWriteCommandAction(project) {
document.setText(accumulatedText)
}
Expand All @@ -130,6 +216,7 @@ class GenerateCommitMessageAction : AnAction() {
// 最终设置完整文本(确保最终一致性)
com.intellij.openapi.application.ApplicationManager.getApplication()
.invokeLater {
if (isTargetDisposed(project, messageDisposable)) return@invokeLater
WriteCommandAction.runWriteCommandAction(project) {
document.setText(fullText.trim())
}
Expand All @@ -145,6 +232,12 @@ class GenerateCommitMessageAction : AnAction() {
currentJob = null
}
)
} catch (e: TimeoutCancellationException) {
showNotification(
project,
"Timed out while loading commit details.",
NotificationType.ERROR
)
} catch (e: CancellationException) {
showNotification(project, "Generation cancelled.", NotificationType.INFORMATION)
} catch (e: Exception) {
Expand All @@ -158,8 +251,36 @@ class GenerateCommitMessageAction : AnAction() {
}
}
}

generationDisposable?.let { disposable ->
Disposer.register(disposable, Disposable { generationJob.cancel() })
generationJob.invokeOnCompletion {
if (!isDisposableDisposed(disposable)) {
Disposer.dispose(disposable)
}
}
}
currentJob = generationJob
generationJob.start()
}

private suspend fun loadChanges(selection: VcsLogCommitSelection): List<Change> =
withTimeout(LOG_DETAILS_TIMEOUT_MS) {
suspendCancellableCoroutine { continuation ->
selection.requestFullDetails { details ->
if (continuation.isActive) {
continuation.resume(details.flatMap { it.changes })
}
}
}
}

private fun isTargetDisposed(project: Project, messageDisposable: Disposable?): Boolean =
project.isDisposed || (messageDisposable != null && isDisposableDisposed(messageDisposable))

@Suppress("DEPRECATION")
private fun isDisposableDisposed(disposable: Disposable): Boolean = Disposer.isDisposed(disposable)

private fun showNotification(project: Project, content: String, type: NotificationType) {
NotificationGroupManager.getInstance()
.getNotificationGroup("AiCommit.Notification")
Expand Down
Loading