diff --git a/README.md b/README.md index 798c78f..ba2d1e1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## ✨ 功能特性 -- **一键生成** — 在 Commit 对话框工具栏中点击闪电按钮,基于已选中的文件变更自动生成 commit message +- **一键生成** — 在 Commit 工具栏或 Git Log 的 Edit Commit Message 弹窗中点击“闪电按钮”,基于对应变更自动生成 commit message - **流式输出** — 实时显示生成过程,无需等待完整响应 - **多 AI 提供商** — 支持 OpenAI、Anthropic、Gemini 及任意 OpenAI 兼容端点 - **提示词模板** — 内置 Conventional Commits / Simple / Detailed 三套模板,支持自定义 diff --git a/build.gradle.kts b/build.gradle.kts index 9f75755..128b0d2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +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" @@ -5,7 +7,11 @@ plugins { } 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) @@ -20,6 +26,7 @@ repositories { dependencies { intellijPlatform { + // 使用最低支持版本编译,避免无意引用 261 新增 API;plugin.xml 不设置 until-build。 intellijIdeaCommunity("2024.3") bundledPlugin("Git4Idea") pluginVerifier() diff --git a/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt b/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt index 5133238..f38e120 100644 --- a/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt +++ b/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt @@ -6,20 +6,29 @@ 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() { @@ -27,25 +36,53 @@ 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 { @@ -57,16 +94,34 @@ class GenerateCommitMessageAction : AnAction() { } } - // 获取已选中的变更 - val includedChanges: List = 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() @@ -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, @@ -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) } @@ -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()) } @@ -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) { @@ -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 = + 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") diff --git a/src/main/kotlin/com/github/fangzc/aicommit/action/RewordCommitContextBridge.kt b/src/main/kotlin/com/github/fangzc/aicommit/action/RewordCommitContextBridge.kt new file mode 100644 index 0000000..36bc99b --- /dev/null +++ b/src/main/kotlin/com/github/fangzc/aicommit/action/RewordCommitContextBridge.kt @@ -0,0 +1,119 @@ +package com.github.fangzc.aicommit.action + +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ex.AnActionListener +import com.intellij.openapi.editor.Document +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.vcs.log.VcsLogCommitSelection +import com.intellij.vcs.log.VcsLogDataKeys + +// JetBrains 内置的“Edit Commit Message”Action ID,243 与 261 均使用该 ID。 +private const val GIT_REWORD_ACTION_ID = "Git.Reword.Commit" + +// PendingContext 只负责跨越“点击菜单 → 创建弹窗”的极短时间窗口。 +// 设定 60 秒上限,降低 Action 被校验拦截或弹窗创建失败后,旧选择误绑定到其他 CommitMessage 的风险。 +private const val PENDING_CONTEXT_TTL_NANOS = 60_000_000_000L + +/** + * 在新版 Git Log 的 Reword Action 与随后创建的 CommitMessage 文档之间桥接提交选择。 + * + * IDEA 2024.3 会直接在弹窗 DataContext 中提供 VCS_LOG_COMMIT_SELECTION; + * IDEA 2026.1 的普通日志右键场景不再提供该数据,也不会设置 changes supplier。 + * 因此必须在 JetBrains Action 尚持有原始 Git Log DataContext 时提前捕获选择, + * 再由 [RewordCommitContextBridge] 将它交给随后创建的消息编辑文档。 + */ +class RewordCommitActionListener : AnActionListener { + + override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) { + // 监听器是应用级的,会收到所有 Action;必须先按 ID 过滤,避免影响其他操作。 + if (ActionManager.getInstance().getId(action) != GIT_REWORD_ACTION_ID) return + + // project 或日志选择不存在时不建立桥接,让原 Action 按 JetBrains 默认逻辑继续执行。 + val project = event.project ?: return + val selection = event.getData(VcsLogDataKeys.VCS_LOG_COMMIT_SELECTION) ?: return + RewordCommitContextBridge.capture(project, selection) + } +} + +internal object RewordCommitContextBridge { + + /** + * 等待绑定到具体 CommitMessage 文档的临时上下文。 + * + * [originalMessage] 在缓存元数据已就绪时,用于确认随后出现的文档确实属于刚才选中的提交; + * 元数据尚未加载时 IDEA 会返回空消息占位对象,此时该字段为 null,绑定仅依赖短 TTL 和一次性消费。 + * [capturedAtNanos] 用单调时钟计算 TTL,不受系统时间调整影响。 + */ + private data class PendingContext( + val selection: VcsLogCommitSelection, + val originalMessage: String?, + val capturedAtNanos: Long, + ) + + // Project 级 Key 只保存尚未被某个弹窗领取的选择。 + private val pendingContextKey = + Key.create("AiCommit.Reword.PendingContext") + + // 一旦匹配成功,就把选择绑定到 Document,确保 modeless 弹窗打开后切换日志行也不会串数据。 + private val documentSelectionKey = + Key.create("AiCommit.Reword.DocumentSelection") + + /** + * 在 Git.Reword.Commit 执行前保存选择。 + * cachedMetadata 可同步读取,但尚未加载时可能只是空消息占位对象,因此空白消息不能参与文档匹配。 + * 这里不在 EDT 上等待完整详情;真正的 changes 仍在点击 AI 按钮后通过 requestFullDetails 异步加载。 + */ + @Synchronized + fun capture(project: Project, selection: VcsLogCommitSelection) { + val originalMessage = selection.cachedMetadata.singleOrNull()?.fullMessage?.takeIf { it.isNotBlank() } + project.putUserData( + pendingContextKey, + PendingContext(selection, originalMessage, System.nanoTime()) + ) + } + + /** + * 获取已绑定的选择,或把当前 Project 的短期 PendingContext 绑定到该文档。 + * + * 绑定成功后会从 Project 中消费掉 pending 数据,使一个日志选择最多只能交给一个弹窗; + * 后续 Action update/actionPerformed 均从 Document 读取同一个选择。 + */ + @Synchronized + fun findOrBind(project: Project, document: Document): VcsLogCommitSelection? { + // 同一弹窗会频繁执行 update,已绑定时直接复用,不再读取可能变化的 Git Log 当前选择。 + document.getUserData(documentSelectionKey)?.let { return it } + + val pending = project.getUserData(pendingContextKey) ?: return null + + // 没有定时清理任务;下一次尝试绑定时检查并清除过期上下文,防止无关消息编辑器误领。 + if (System.nanoTime() - pending.capturedAtNanos > PENDING_CONTEXT_TTL_NANOS) { + project.putUserData(pendingContextKey, null) + return null + } + + // 261 的 Reword 弹窗会先写入原提交消息再创建工具栏;缓存消息可用时,再用它匹配 Document 身份。 + // 对换行符和结尾空白做归一化,兼容 Git/Windows 与 IDEA Document 的行分隔符差异。 + if (pending.originalMessage != null && + normalizeMessage(document.text) != normalizeMessage(pending.originalMessage) + ) { + return null + } + + // 先消费 Project pending,再绑定 Document;弹窗后续生命周期只依赖 Document 自身。 + project.putUserData(pendingContextKey, null) + document.putUserData(documentSelectionKey, pending.selection) + return pending.selection + } + + /** 已从旧版 DataContext 或 changes supplier 获得更准确数据时,丢弃不再需要的桥接上下文。 */ + @Synchronized + fun clearPending(project: Project) { + project.putUserData(pendingContextKey, null) + } + + private fun normalizeMessage(message: String): String = + message.replace("\r\n", "\n").trimEnd() +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 8ee039d..6a21c21 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -33,6 +33,8 @@ com.intellij.modules.vcs Git4Idea + messages.AiCommitBundle + @@ -49,11 +51,18 @@ displayType="BALLOON"/> + + + + + diff --git a/src/main/resources/messages/AiCommitBundle.properties b/src/main/resources/messages/AiCommitBundle.properties index 678d706..3bc4815 100644 --- a/src/main/resources/messages/AiCommitBundle.properties +++ b/src/main/resources/messages/AiCommitBundle.properties @@ -1,4 +1,6 @@ notification.group.title=AI Commit Message +action.AiCommit.Generate.text=AI Generate +action.AiCommit.Generate.description=Generate a commit message with AI based on the selected changes action.generate.text=Generate AI Commit Message action.generate.description=Generate commit message using AI based on selected changes diff --git a/src/main/resources/messages/AiCommitBundle_zh.properties b/src/main/resources/messages/AiCommitBundle_zh.properties index fa8e1d2..b4984f0 100644 --- a/src/main/resources/messages/AiCommitBundle_zh.properties +++ b/src/main/resources/messages/AiCommitBundle_zh.properties @@ -1,4 +1,6 @@ notification.group.title=AI Commit Message +action.AiCommit.Generate.text=AI 生成 +action.AiCommit.Generate.description=基于所选代码变更,使用 AI 生成 Commit Message action.generate.text=生成 AI Commit Message action.generate.description=基于已选中的代码变更,使用 AI 生成 Commit Message