@@ -2,7 +2,6 @@ package cc.unitmesh.git.actions.vcs
2
2
3
3
import cc.unitmesh.devti.AutoDevNotifications
4
4
import cc.unitmesh.devti.actions.chat.base.ChatBaseAction
5
- import cc.unitmesh.devti.flow.kanban.impl.GitHubIssue
6
5
import cc.unitmesh.devti.gui.chat.message.ChatActionType
7
6
import cc.unitmesh.devti.llms.LlmFactory
8
7
import cc.unitmesh.devti.settings.locale.LanguageChangedCallback.presentationText
@@ -14,158 +13,57 @@ import cc.unitmesh.devti.template.context.TemplateContext
14
13
import cc.unitmesh.devti.util.AutoDevCoroutineScope
15
14
import cc.unitmesh.devti.util.parser.CodeFence
16
15
import cc.unitmesh.devti.vcs.VcsUtil
17
- import com.intellij.ide.TextCopyProvider
18
16
import com.intellij.openapi.actionSystem.ActionUpdateThread
19
17
import com.intellij.openapi.actionSystem.AnActionEvent
20
- import com.intellij.openapi.actionSystem.PlatformDataKeys.COPY_PROVIDER
21
18
import com.intellij.openapi.application.ApplicationManager
22
19
import com.intellij.openapi.application.invokeLater
23
20
import com.intellij.openapi.components.service
24
21
import com.intellij.openapi.diagnostic.logger
25
- import com.intellij.openapi.progress.ProgressIndicator
26
- import com.intellij.openapi.progress.ProgressManager
27
- import com.intellij.openapi.progress.Task
28
22
import com.intellij.openapi.project.Project
29
- import com.intellij.openapi.ui.Messages
30
- import com.intellij.openapi.ui.popup.JBPopup
31
- import com.intellij.openapi.ui.popup.JBPopupFactory
32
- import com.intellij.openapi.ui.popup.JBPopupListener
33
- import com.intellij.openapi.ui.popup.LightweightWindowEvent
34
23
import com.intellij.openapi.vcs.VcsDataKeys
35
24
import com.intellij.openapi.vcs.changes.Change
36
25
import com.intellij.openapi.vcs.changes.CurrentContentRevision
37
26
import com.intellij.openapi.vcs.ui.CommitMessage
38
27
import com.intellij.openapi.vfs.VirtualFile
39
- import com.intellij.ui.ColoredListCellRenderer
40
- import com.intellij.ui.awt.RelativePoint
41
- import com.intellij.ui.speedSearch.SpeedSearchUtil.applySpeedSearchHighlighting
42
- import com.intellij.util.containers.nullize
43
- import com.intellij.util.ui.JBUI.scale
44
28
import com.intellij.vcs.commit.CommitWorkflowUi
45
29
import com.intellij.vcs.log.VcsLogFilterCollection
46
30
import com.intellij.vcs.log.VcsLogProvider
47
31
import com.intellij.vcs.log.impl.VcsProjectLog
48
32
import com.intellij.vcs.log.visible.filters.VcsLogFilterObject
49
33
import kotlinx.coroutines.*
50
34
import kotlinx.coroutines.flow.*
51
- import org.kohsuke.github.GHIssue
52
- import org.kohsuke.github.GHIssueState
53
- import java.awt.Point
54
- import java.util.concurrent.ConcurrentHashMap
55
- import javax.swing.JList
56
- import javax.swing.ListSelectionModel.SINGLE_SELECTION
57
-
58
- data class IssueDisplayItem (val issue : GHIssue , val displayText : String )
59
-
60
- /* *
61
- * Cache entry for GitHub issues with timestamp for TTL management
62
- */
63
- private data class IssueCacheEntry (
64
- val issues : List <IssueDisplayItem >,
65
- val timestamp : Long
66
- ) {
67
- fun isExpired (ttlMs : Long ): Boolean = System .currentTimeMillis() - timestamp > ttlMs
68
- }
69
35
70
36
class CommitMessageSuggestionAction : ChatBaseAction () {
71
37
private val logger = logger<CommitMessageSuggestionAction >()
72
38
73
39
init {
74
40
presentationText(" settings.autodev.others.commitMessage" , templatePresentation)
75
- isEnabledInModalContext = true
76
41
}
77
42
78
43
override fun getActionUpdateThread (): ActionUpdateThread = ActionUpdateThread .BGT
79
44
80
45
private var currentJob: Job ? = null
81
- private var selectedIssue: IssueDisplayItem ? = null
82
- private var currentChanges: List <Change >? = null
83
- private var currentEvent: AnActionEvent ? = null
84
- private var isGitHubRepository: Boolean = false
85
-
86
- companion object {
87
- // In-memory cache for GitHub issues with 5-minute TTL
88
- private val issuesCache = ConcurrentHashMap <String , IssueCacheEntry >()
89
- private const val CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes
90
- private const val MAX_CACHE_SIZE = 50 // Maximum number of repositories to cache
91
-
92
- // Cache statistics
93
- @Volatile
94
- private var cacheHits = 0
95
-
96
- @Volatile
97
- private var cacheMisses = 0
98
-
99
- /* *
100
- * Clear all cached GitHub issues
101
- */
102
- fun clearIssuesCache () {
103
- issuesCache.clear()
104
- logger<CommitMessageSuggestionAction >().info(" GitHub issues cache cleared manually" )
105
- }
106
-
107
- /* *
108
- * Get cache statistics for debugging
109
- */
110
- fun getCacheStats (): String {
111
- val total = cacheHits + cacheMisses
112
- val hitRate = if (total > 0 ) (cacheHits * 100.0 / total) else 0.0
113
- return " Cache Stats - Hits: $cacheHits , Misses: $cacheMisses , Hit Rate: ${" %.2f" .format(hitRate)} %, Entries: ${issuesCache.size} "
114
- }
115
- }
116
46
117
47
override fun getActionType (): ChatActionType = ChatActionType .GEN_COMMIT_MESSAGE
118
48
119
49
override fun update (e : AnActionEvent ) {
120
- val project = e.project
121
50
val data = e.getData(VcsDataKeys .COMMIT_MESSAGE_CONTROL )
122
-
123
- if (data == null || project == null ) {
51
+ if (data == null ) {
124
52
e.presentation.icon = AutoDevStatus .WAITING .icon
125
53
e.presentation.isEnabled = false
126
54
return
127
55
}
128
56
129
- val prompting = project.service<VcsPrompting >()
130
- val changes: List <Change > = prompting.getChanges()
131
-
132
- // Check if it's a GitHub repository (safe to call in BGT)
133
- isGitHubRepository = GitHubIssue .isGitHubRepository(project)
134
-
135
- // Update presentation text based on whether it's a GitHub repository
136
- if (isGitHubRepository) {
137
- e.presentation.text = " Smart Commit Message (GitHub Enhanced)"
138
- e.presentation.description = " Generate commit message with AI or GitHub issue integration"
139
- } else {
140
- e.presentation.text = " Smart Commit Message"
141
- e.presentation.description = " Generate commit message with AI"
142
- }
143
-
144
- // Update icon based on current job status
145
- if (currentJob?.isActive == true ) {
146
- e.presentation.icon = AutoDevStatus .InProgress .icon
147
- e.presentation.text = " Cancel Commit Message Generation"
148
- e.presentation.description = " Click to cancel current generation"
149
- } else {
150
- e.presentation.icon = AutoDevStatus .Ready .icon
151
- }
57
+ val prompting = e.project?.service<VcsPrompting >()
58
+ val changes: List <Change > = prompting?.getChanges() ? : listOf ()
152
59
60
+ e.presentation.icon = AutoDevStatus .Ready .icon
153
61
e.presentation.isEnabled = changes.isNotEmpty()
154
62
}
155
63
156
64
override fun executeAction (event : AnActionEvent ) {
157
65
val project = event.project ? : return
158
66
159
- // If there's an active job, cancel it
160
- if (currentJob?.isActive == true ) {
161
- currentJob?.cancel()
162
- currentJob = null
163
- AutoDevNotifications .notify(project, " Commit message generation cancelled." )
164
- return
165
- }
166
-
167
- val commitMessage = getCommitMessage(event) ? : return
168
-
169
67
val commitWorkflowUi = VcsUtil .getCommitWorkFlowUi(event)
170
68
if (commitWorkflowUi == null ) {
171
69
AutoDevNotifications .notify(project, " Cannot get commit workflow UI." )
@@ -178,144 +76,63 @@ class CommitMessageSuggestionAction : ChatBaseAction() {
178
76
return
179
77
}
180
78
181
- // Store current state for later use
182
- currentChanges = changes
183
- currentEvent = event
184
- selectedIssue = null
185
-
186
- // For GitHub repositories, show issue selection popup directly
187
- // For non-GitHub repositories, generate AI commit message directly
188
- if (isGitHubRepository) {
189
- generateGitHubIssueCommitMessage(project, commitMessage, event)
190
- } else {
191
- generateAICommitMessage(project, commitMessage, changes)
79
+ val diffContext = project.service<VcsPrompting >().prepareContext(changes)
80
+ if (diffContext.isEmpty() || diffContext == " \n " ) {
81
+ logger.warn(" Diff context is empty or cannot get enough useful context." )
82
+ AutoDevNotifications .notify(project, " Diff context is empty or cannot get enough useful context." )
83
+ return
192
84
}
193
- }
194
85
195
- private fun getCommitMessage (e : AnActionEvent ) = e.getData(VcsDataKeys .COMMIT_MESSAGE_CONTROL ) as ? CommitMessage
86
+ val editorField = (event.getData(VcsDataKeys .COMMIT_MESSAGE_CONTROL ) as CommitMessage ).editorField
87
+ val originText = editorField.editor?.selectionModel?.selectedText ? : " "
196
88
197
- private fun generateGitHubIssueCommitMessage ( project : Project , commitMessage : CommitMessage , event : AnActionEvent ) {
198
- val task = object : Task . Backgroundable (project, " Loading GitHub issues " , true ) {
199
- override fun run ( indicator : ProgressIndicator ) {
200
- // Fix: Set indeterminate to false before setting fraction
201
- indicator.isIndeterminate = false
202
- indicator.text = " Connecting to GitHub... "
203
- indicator.fraction = 0.1
89
+ currentJob?.cancel()
90
+ editorField.text = " "
91
+ event.presentation.icon = AutoDevStatus . InProgress .icon
92
+
93
+ ApplicationManager .getApplication().executeOnPooledThread {
94
+ val prompt = generateCommitMessage(diffContext, project, originText)
95
+ logger.info(prompt)
204
96
205
- val job = AutoDevCoroutineScope .scope(project).launch {
97
+ try {
98
+ val stream = LlmFactory .create(project).stream(prompt, " " , false )
99
+ currentJob = AutoDevCoroutineScope .scope(project).launch {
206
100
try {
207
- val issues = withTimeout(5000 ) {
208
- indicator.text = " Fetching repository issues..."
209
- indicator.fraction = 0.5
210
- fetchGitHubIssues(project)
101
+ stream.cancellable().collect { chunk ->
102
+ invokeLater {
103
+ if (isActive) {
104
+ editorField.text + = chunk
105
+ }
106
+ }
211
107
}
212
- indicator.fraction = 0.9
213
-
214
- ApplicationManager .getApplication().invokeLater {
215
- if (issues.isEmpty()) {
216
- // No issues found, fall back to AI generation
217
- val changes = currentChanges ? : return @invokeLater
218
- generateAICommitMessage(project, commitMessage, changes)
219
- } else {
220
- createIssuesPopup(commitMessage, issues).showInBestPositionFor(event.dataContext )
108
+
109
+ val text = editorField.text
110
+ if (isActive && text.startsWith( " ``` " ) && text.endsWith( " ``` " )) {
111
+ invokeLater {
112
+ editorField.text = CodeFence .parse(text).text
113
+ }
114
+ } else if (isActive) {
115
+ invokeLater {
116
+ editorField.text = text.removePrefix( " ``` \n " ).removeSuffix( " ``` " )
221
117
}
222
118
}
223
- } catch (ex: TimeoutCancellationException ) {
224
- ApplicationManager .getApplication().invokeLater {
225
- logger.info(" GitHub issues fetch timed out after 5 seconds, falling back to AI generation" )
226
- AutoDevNotifications .notify(
227
- project,
228
- " GitHub connection timeout, generating commit message without issue context."
229
- )
230
- // Fall back to AI generation when timeout occurs
231
- val changes = currentChanges ? : return @invokeLater
232
- generateAICommitMessage(project, commitMessage, changes)
119
+ } catch (e: Exception ) {
120
+ logger.error(" Error during commit message generation" , e)
121
+ invokeLater {
122
+ AutoDevNotifications .notify(project, " Error generating commit message: ${e.message} " )
233
123
}
234
- } catch (ex: Exception ) {
235
- ApplicationManager .getApplication().invokeLater {
236
- logger.warn(" Failed to fetch GitHub issues, falling back to AI generation" , ex)
237
- // Fall back to AI generation when GitHub issues fetch fails
238
- val changes = currentChanges ? : return @invokeLater
239
- generateAICommitMessage(project, commitMessage, changes)
124
+ } finally {
125
+ invokeLater {
126
+ event.presentation.icon = AutoDevStatus .Ready .icon
240
127
}
241
128
}
242
129
}
243
-
244
- runBlocking {
245
- try {
246
- job.join()
247
- } catch (ex: Exception ) {
248
- logger.warn(" Error waiting for GitHub issues fetch" , ex)
249
- }
250
- }
130
+ } catch (e: Exception ) {
131
+ logger.error(" Failed to start commit message generation" , e)
132
+ event.presentation.icon = AutoDevStatus .Error .icon
133
+ AutoDevNotifications .notify(project, " Failed to start commit message generation: ${e.message} " )
251
134
}
252
135
}
253
- ProgressManager .getInstance().run (task)
254
- }
255
-
256
- private fun fetchGitHubIssues (project : Project ): List <IssueDisplayItem > {
257
- val ghRepository =
258
- GitHubIssue .parseGitHubRepository(project) ? : throw IllegalStateException (" Not a GitHub repository" )
259
-
260
- // Generate cache key based on repository URL
261
- val cacheKey = " ${ghRepository.url} /issues"
262
-
263
- // Check cache first
264
- val cachedEntry = issuesCache[cacheKey]
265
- if (cachedEntry != null && ! cachedEntry.isExpired(CACHE_TTL_MS )) {
266
- cacheHits++
267
- logger.info(" Using cached GitHub issues for repository: ${ghRepository.url} (${getCacheStats()} )" )
268
- return cachedEntry.issues
269
- }
270
-
271
- // Cache miss - fetch fresh data from GitHub API
272
- cacheMisses++
273
- logger.info(" Fetching fresh GitHub issues for repository: ${ghRepository.url} (${getCacheStats()} )" )
274
- val issues = ghRepository.getIssues(GHIssueState .OPEN ).map { issue ->
275
- val displayText = " #${issue.number} - ${issue.title} "
276
- IssueDisplayItem (issue, displayText)
277
- }
278
-
279
- // Cache the results
280
- issuesCache[cacheKey] = IssueCacheEntry (issues, System .currentTimeMillis())
281
-
282
- // Clean up expired entries and enforce size limit
283
- cleanupExpiredCacheEntries()
284
- enforceCacheSizeLimit()
285
-
286
- return issues
287
- }
288
-
289
- /* *
290
- * Clean up expired cache entries to prevent memory leaks
291
- */
292
- private fun cleanupExpiredCacheEntries () {
293
- val currentTime = System .currentTimeMillis()
294
- val expiredKeys = issuesCache.entries
295
- .filter { it.value.timestamp + CACHE_TTL_MS < currentTime }
296
- .map { it.key }
297
-
298
- expiredKeys.forEach { key ->
299
- issuesCache.remove(key)
300
- logger.debug(" Removed expired cache entry for key: $key " )
301
- }
302
- }
303
-
304
- /* *
305
- * Enforce cache size limit by removing oldest entries
306
- */
307
- private fun enforceCacheSizeLimit () {
308
- if (issuesCache.size <= MAX_CACHE_SIZE ) return
309
-
310
- val sortedEntries = issuesCache.entries.sortedBy { it.value.timestamp }
311
- val toRemove = sortedEntries.take(issuesCache.size - MAX_CACHE_SIZE )
312
-
313
- toRemove.forEach { entry ->
314
- issuesCache.remove(entry.key)
315
- logger.debug(" Removed oldest cache entry to enforce size limit: ${entry.key} " )
316
- }
317
-
318
- logger.info(" Enforced cache size limit. Removed ${toRemove.size} entries. Current size: ${issuesCache.size} " )
319
136
}
320
137
321
138
/* *
@@ -367,155 +184,6 @@ class CommitMessageSuggestionAction : ChatBaseAction() {
367
184
return builder.toString()
368
185
}
369
186
370
- private fun createIssuesPopup (commitMessage : CommitMessage , issues : List <IssueDisplayItem >): JBPopup {
371
- var chosenIssue: IssueDisplayItem ? = null
372
- return JBPopupFactory .getInstance().createPopupChooserBuilder(issues)
373
- .setTitle(" Select GitHub Issue (ESC to skip)" )
374
- .setVisibleRowCount(10 )
375
- .setSelectionMode(SINGLE_SELECTION )
376
- .setItemSelectedCallback { chosenIssue = it }
377
- .setItemChosenCallback {
378
- chosenIssue = it
379
- }
380
- .setRenderer(object : ColoredListCellRenderer <IssueDisplayItem >() {
381
- override fun customizeCellRenderer (
382
- list : JList <out IssueDisplayItem >,
383
- value : IssueDisplayItem ,
384
- index : Int ,
385
- selected : Boolean ,
386
- hasFocus : Boolean
387
- ) {
388
- append(" #${value.issue.number} " , com.intellij.ui.SimpleTextAttributes .GRAYED_ATTRIBUTES )
389
- append(value.issue.title)
390
- val labels = value.issue.labels.map { it.name }
391
- if (labels.isNotEmpty()) {
392
- append(" " )
393
- labels.forEach { label ->
394
- append(" [$label ] " , com.intellij.ui.SimpleTextAttributes .GRAY_ITALIC_ATTRIBUTES )
395
- }
396
- }
397
- applySpeedSearchHighlighting(list, this , true , selected)
398
- }
399
- })
400
- .addListener(object : JBPopupListener {
401
- override fun beforeShown (event : LightweightWindowEvent ) {
402
- val popup = event.asPopup()
403
- val relativePoint = RelativePoint (commitMessage.editorField, Point (0 , - scale(3 )))
404
- val screenPoint = Point (relativePoint.screenPoint).apply { translate(0 , - popup.size.height) }
405
- popup.setLocation(screenPoint)
406
- }
407
-
408
- override fun onClosed (event : LightweightWindowEvent ) {
409
- // IDEA-195094 Regression: New CTRL-E in "commit changes" breaks keyboard shortcuts
410
- commitMessage.editorField.requestFocusInWindow()
411
-
412
- if (chosenIssue != null ) {
413
- // User selected an issue
414
- handleIssueSelection(chosenIssue!! , commitMessage)
415
- } else {
416
- // User cancelled (ESC) - skip issue selection and generate with AI
417
- handleSkipIssueSelection(commitMessage)
418
- }
419
- }
420
- })
421
- .setNamerForFiltering { it.displayText }
422
- .setAutoPackHeightOnFiltering(true )
423
- .createPopup()
424
- .apply {
425
- setDataProvider { dataId ->
426
- when (dataId) {
427
- // default list action does not work as "CopyAction" is invoked first, but with other copy provider
428
- COPY_PROVIDER .name -> object : TextCopyProvider () {
429
- override fun getActionUpdateThread (): ActionUpdateThread = ActionUpdateThread .EDT
430
- override fun getTextLinesToCopy () = listOfNotNull(chosenIssue?.displayText).nullize()
431
- }
432
-
433
- else -> null
434
- }
435
- }
436
- }
437
- }
438
-
439
- private fun handleIssueSelection (issueItem : IssueDisplayItem , commitMessage : CommitMessage ) {
440
- // Store the selected issue for AI generation
441
- selectedIssue = issueItem
442
- val project = commitMessage.editorField.project ? : return
443
- val changes = currentChanges ? : return
444
- val event = currentEvent ? : return
445
-
446
- // Generate AI commit message with issue context
447
- generateAICommitMessage(project, commitMessage, changes)
448
- }
449
-
450
- private fun handleSkipIssueSelection (commitMessage : CommitMessage ) {
451
- // Skip issue selection, generate with AI only
452
- selectedIssue = null
453
- val project = commitMessage.editorField.project ? : return
454
- val changes = currentChanges ? : return
455
- // Generate AI commit message without issue context
456
- generateAICommitMessage(project, commitMessage, changes)
457
- }
458
-
459
- private fun generateAICommitMessage (project : Project , commitMessage : CommitMessage , changes : List <Change >) {
460
- val diffContext = project.service<VcsPrompting >().prepareContext(changes)
461
-
462
- if (diffContext.isEmpty() || diffContext == " \n " ) {
463
- logger.warn(" Diff context is empty or cannot get enough useful context." )
464
- AutoDevNotifications .notify(project, " Diff context is empty or cannot get enough useful context." )
465
- return
466
- }
467
-
468
- val editorField = commitMessage.editorField
469
- val originText = editorField.editor?.selectionModel?.selectedText ? : " "
470
-
471
- currentJob?.cancel()
472
- editorField.text = " "
473
-
474
- ApplicationManager .getApplication().executeOnPooledThread {
475
- val prompt = generateCommitMessage(diffContext, project, originText)
476
- logger.info(prompt)
477
-
478
- try {
479
- val stream = LlmFactory .create(project).stream(prompt, " " , false )
480
-
481
- currentJob = AutoDevCoroutineScope .scope(project).launch {
482
- try {
483
- stream.cancellable().collect { chunk ->
484
- invokeLater {
485
- if (isActive) {
486
- editorField.text + = chunk
487
- }
488
- }
489
- }
490
-
491
- val text = editorField.text
492
- if (isActive && text.startsWith(" ```" ) && text.endsWith(" ```" )) {
493
- invokeLater {
494
- editorField.text = CodeFence .parse(text).text
495
- }
496
- } else if (isActive) {
497
- invokeLater {
498
- editorField.text = text.removePrefix(" ```\n " ).removeSuffix(" ```" )
499
- }
500
- }
501
- } catch (e: Exception ) {
502
- logger.error(" Error during commit message generation" , e)
503
- invokeLater {
504
- AutoDevNotifications .notify(project, " Error generating commit message: ${e.message} " )
505
- }
506
- } finally {
507
- // Job completed, will be reflected in next update() call
508
- currentJob = null
509
- }
510
- }
511
- } catch (e: Exception ) {
512
- logger.error(" Failed to start commit message generation" , e)
513
- currentJob = null
514
- AutoDevNotifications .notify(project, " Failed to start commit message generation: ${e.message} " )
515
- }
516
- }
517
- }
518
-
519
187
private fun generateCommitMessage (diff : String , project : Project , originText : String ): String {
520
188
val templateRender = TemplateRender (GENIUS_PRACTISES )
521
189
val template = templateRender.getTemplate(" gen-commit-msg.vm" )
@@ -527,22 +195,10 @@ class CommitMessageSuggestionAction : ChatBaseAction() {
527
195
" "
528
196
}
529
197
530
- val issue = selectedIssue?.issue
531
- val issueDetail = if (issue != null ) {
532
- buildString {
533
- appendLine(" Title: ${issue.title} " )
534
- if (! issue.body.isNullOrBlank()) {
535
- appendLine(" Description: ${issue.body} " )
536
- }
537
- }
538
- } else " "
539
-
540
198
templateRender.context = CommitMsgGenContext (
541
199
historyExamples = historyExamples,
542
200
diffContent = diff,
543
- originText = originText,
544
- issueId = issue?.number?.toString() ? : " " ,
545
- issueDetail = issueDetail
201
+ originText = originText
546
202
)
547
203
548
204
val prompter = templateRender.renderTemplate(template)
@@ -571,7 +227,4 @@ data class CommitMsgGenContext(
571
227
var diffContent : String = " " ,
572
228
// the origin commit message which is to be optimized
573
229
val originText : String = " " ,
574
- // GitHub issue information if selected
575
- val issueId : String = " " ,
576
- val issueDetail : String = " " ,
577
230
) : TemplateContext
0 commit comments