From d296def6ffbd28ba339fee04ca48b82b5545f1df Mon Sep 17 00:00:00 2001 From: leiguoqing <191789784@qq.com> Date: Fri, 7 Aug 2026 17:43:10 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(action):=20=E6=89=A9=E5=B1=95=20AI=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81=20Git=20?= =?UTF-8?q?Log=20=E7=BC=96=E8=BE=91=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 resource bundle 配置支持国际化文本 - 实现 Git Log Edit Commit Message 弹窗的变更获取逻辑 - 添加对 VCS_LOG_COMMIT_SELECTION 数据键的支持 - 集成 Disposable 机制管理生成任务生命周期 - 实现超时处理和取消机制优化用户体验 - 更新文档描述支持新的使用场景 --- README.md | 2 +- .../action/GenerateCommitMessageAction.kt | 115 ++++++++++++++---- src/main/resources/META-INF/plugin.xml | 4 +- .../messages/AiCommitBundle.properties | 2 + .../messages/AiCommitBundle_zh.properties | 2 + 5 files changed, 98 insertions(+), 27 deletions(-) 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/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt b/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt index 5133238..a559481 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,28 @@ 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.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 +35,25 @@ 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 document = e.getData(VcsDataKeys.COMMIT_MESSAGE_DOCUMENT) + val workflowUi = e.getData(VcsDataKeys.COMMIT_WORKFLOW_UI) + val logSelection = e.getData(VcsLogDataKeys.VCS_LOG_COMMIT_SELECTION) + + e.presentation.isEnabledAndVisible = document != null && + (workflowUi != null || logSelection?.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 +65,14 @@ 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 - ) + // 普通提交窗口提供 COMMIT_WORKFLOW_UI;历史提交改写弹窗提供 VCS_LOG_COMMIT_SELECTION + val workflowUi = e.getData(VcsDataKeys.COMMIT_WORKFLOW_UI) + val logSelection = e.getData(VcsLogDataKeys.VCS_LOG_COMMIT_SELECTION) + if (workflowUi == null && logSelection == null) { + showNotification(project, "Cannot access commit changes.", NotificationType.ERROR) return } + val workflowChanges = workflowUi?.getIncludedChanges() // 检查 API Key 是否已配置 val settings = PluginSettings.getInstance() @@ -79,12 +85,37 @@ 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 + val includedChanges = workflowChanges ?: loadChanges(logSelection!!) + 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 +152,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 +162,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 +178,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 +197,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/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 8ee039d..ddbba3a 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 + @@ -52,8 +54,6 @@ 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 From 906bcdbc7317dae4c16d4104736fedff8c6fd12e Mon Sep 17 00:00:00 2001 From: leiguoqing <191789784@qq.com> Date: Fri, 7 Aug 2026 18:21:03 +0800 Subject: [PATCH 2/4] =?UTF-8?q?chore(build):=20=E6=9B=B4=E6=96=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将项目版本从 1.1.2 更新为 1.1.3 - 保持 JVM 工具链配置为版本 21 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 9f75755..c2923d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "com.github.fangzc" -version = "1.1.2" +version = "1.1.3" kotlin { jvmToolchain(21) From a7e27b2b2cf363618bd6d9592f482c41f3ae493d Mon Sep 17 00:00:00 2001 From: leiguoqing <191789784@qq.com> Date: Fri, 7 Aug 2026 18:33:46 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(plugin):=20=E6=94=AF=E6=8C=812026?= =?UTF-8?q?=E6=96=B0=E7=89=88=20Git=20Log=20=E7=BC=96=E8=BE=91=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E7=9A=84=E5=8F=98=E6=9B=B4=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 CommitMessage.CHANGES_SUPPLIER_KEY 来获取变更数据 - 更新条件判断逻辑以支持多种变更来源 - 重构 includedChanges 获取逻辑以处理多个可能的数据源 - 添加空列表回退以确保不会出现空指针异常 - 优化后台进度条中的变更加载处理流程 --- build.gradle.kts | 2 +- .../action/GenerateCommitMessageAction.kt | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index c2923d1..794814d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "com.github.fangzc" -version = "1.1.3" +version = "1.1.4" kotlin { jvmToolchain(21) 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 a559481..a477701 100644 --- a/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt +++ b/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt @@ -17,6 +17,7 @@ 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.log.VcsLogCommitSelection import com.intellij.vcs.log.VcsLogDataKeys @@ -45,9 +46,10 @@ class GenerateCommitMessageAction : AnAction() { 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) e.presentation.isEnabledAndVisible = document != null && - (workflowUi != null || logSelection?.commits?.isNotEmpty() == true) + (workflowUi != null || changesSupplier != null || logSelection?.commits?.isNotEmpty() == true) } override fun actionPerformed(e: AnActionEvent) { @@ -68,7 +70,11 @@ class GenerateCommitMessageAction : AnAction() { // 普通提交窗口提供 COMMIT_WORKFLOW_UI;历史提交改写弹窗提供 VCS_LOG_COMMIT_SELECTION val workflowUi = e.getData(VcsDataKeys.COMMIT_WORKFLOW_UI) val logSelection = e.getData(VcsLogDataKeys.VCS_LOG_COMMIT_SELECTION) - if (workflowUi == null && logSelection == null) { + // 新版 Git Log 编辑弹窗通过 CommitMessage 文档提供变更 Supplier + val suppliedChanges = document.getUserData(CommitMessage.CHANGES_SUPPLIER_KEY) + ?.get() + ?.toList() + if (workflowUi == null && suppliedChanges == null && logSelection == null) { showNotification(project, "Cannot access commit changes.", NotificationType.ERROR) return } @@ -105,7 +111,10 @@ class GenerateCommitMessageAction : AnAction() { ) { withBackgroundProgress(project, "Generating commit message...") { try { - val includedChanges = workflowChanges ?: loadChanges(logSelection!!) + val includedChanges = workflowChanges + ?: suppliedChanges + ?: logSelection?.let { loadChanges(it) } + ?: emptyList() if (includedChanges.isEmpty()) { showNotification( project, From 655a22812540623ba453fb16a950bc0b470183b7 Mon Sep 17 00:00:00 2001 From: leiguoqing <191789784@qq.com> Date: Fri, 7 Aug 2026 19:10:04 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(reword):=20=E6=94=AF=E6=8C=81IDEA=2020?= =?UTF-8?q?26.1=20Git=20Log=20Reword=E5=BC=B9=E7=AA=97=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 新增RewordCommitContextBridge类,通过AnActionListener监听Git.Reword.Commit动作,捕获日志选择并桥接到CommitMessage文档 - 重构GenerateCommitMessageAction的update和actionPerformed方法,优先使用原生DataContext,否则通过桥接获取选择 - 调整build.gradle.kts,升级版本至1.1.5,添加localIdePath属性,注释最低兼容基线为2024.3 - 在plugin.xml中注册RewordCommitActionListener 影响范围: - 插件在IDEA 2024.3及2026.1上的Reword弹窗功能 - 普通Commit窗口和Git Log Reword入口的按钮可见性逻辑 关联事项: - 无 --- build.gradle.kts | 9 +- .../action/GenerateCommitMessageAction.kt | 55 +++++++- .../action/RewordCommitContextBridge.kt | 119 ++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 9 ++ 4 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/com/github/fangzc/aicommit/action/RewordCommitContextBridge.kt diff --git a/build.gradle.kts b/build.gradle.kts index 794814d..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.4" +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 a477701..f38e120 100644 --- a/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt +++ b/src/main/kotlin/com/github/fangzc/aicommit/action/GenerateCommitMessageAction.kt @@ -43,13 +43,40 @@ class GenerateCommitMessageAction : AnAction() { override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { + 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 || logSelection?.commits?.isNotEmpty() == true) + (workflowUi != null || changesSupplier != null || + effectiveLogSelection?.commits?.isNotEmpty() == true) } override fun actionPerformed(e: AnActionEvent) { @@ -67,14 +94,30 @@ class GenerateCommitMessageAction : AnAction() { } } - // 普通提交窗口提供 COMMIT_WORKFLOW_UI;历史提交改写弹窗提供 VCS_LOG_COMMIT_SELECTION + // actionPerformed 必须重新解析数据,不能依赖 update 时的 AnActionEvent/DataContext 快照。 val workflowUi = e.getData(VcsDataKeys.COMMIT_WORKFLOW_UI) val logSelection = e.getData(VcsLogDataKeys.VCS_LOG_COMMIT_SELECTION) - // 新版 Git Log 编辑弹窗通过 CommitMessage 文档提供变更 Supplier + + // 原 Action DataContext 含非空 SELECTED_CHANGES_IN_DETAILS 时,IDE 会把对应 changes Supplier + // 存到 Document userData;普通 Git Log 行右键不满足此条件。 val suppliedChanges = document.getUserData(CommitMessage.CHANGES_SUPPLIER_KEY) ?.get() ?.toList() - if (workflowUi == null && suppliedChanges == null && logSelection == null) { + + // 普通 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 } @@ -111,9 +154,11 @@ class GenerateCommitMessageAction : AnAction() { ) { withBackgroundProgress(project, "Generating commit message...") { try { + // 数据源优先级:当前 Commit 勾选项 > 弹窗直接提供的 changes > Git Log selection 完整详情。 + // 保留该顺序可确保普通 Commit 不会误用之前缓存的历史提交。 val includedChanges = workflowChanges ?: suppliedChanges - ?: logSelection?.let { loadChanges(it) } + ?: effectiveLogSelection?.let { loadChanges(it) } ?: emptyList() if (includedChanges.isEmpty()) { showNotification( 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 ddbba3a..6a21c21 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -51,6 +51,15 @@ displayType="BALLOON"/> + + + + +