mirror of
https://gitflic.ru/project/openide/openide.git
synced 2025-12-13 15:52:01 +07:00
[ghai] Move AI review into LLM plugin module
(cherry picked from commit 5d96d51f28b0e63c660b56843245f40a1603355e) (cherry picked from commit 2f43f1000ec5b565f6390a6e00388a2836300539) IJ-CR-148445 GitOrigin-RevId: 9802819a1282435cdde7677313a5d57a5f3c0d01
This commit is contained in:
committed by
intellij-monorepo-bot
parent
c1a22690f7
commit
df57020a83
@@ -21,7 +21,6 @@
|
||||
<dependencies>
|
||||
<plugin id="com.intellij.modules.lang"/>
|
||||
<plugin id="Git4Idea"/>
|
||||
<plugin id="com.intellij.ml.llm"/>
|
||||
<module name="intellij.platform.collaborationTools"/>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -80,9 +80,8 @@
|
||||
<orderEntry type="module" module-name="intellij.platform.util.coroutines" />
|
||||
<orderEntry type="library" scope="TEST" name="JUnit4" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="kotlinx-coroutines-test" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.ml.llm.core" />
|
||||
<orderEntry type="module" module-name="intellij.ml.llm.core.privacy" />
|
||||
<orderEntry type="module" module-name="intellij.ml.llm.privacy" />
|
||||
<orderEntry type="library" scope="TEST" name="io.mockk" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="io.mockk.jvm" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide.ui" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -1,7 +1,6 @@
|
||||
<idea-plugin package="org.jetbrains.plugins.github">
|
||||
|
||||
<content>
|
||||
<module name="intellij.vcs.github/ai"/>
|
||||
<module name="intellij.vcs.github/tracker"/>
|
||||
</content>
|
||||
|
||||
@@ -14,6 +13,10 @@
|
||||
<extensionPoint qualifiedName="intellij.vcs.github.commentViewModelProvider"
|
||||
interface="org.jetbrains.plugins.github.pullrequest.ui.diff.GHPRAICommentViewModelProvider"
|
||||
dynamic="true"/>
|
||||
<extensionPoint qualifiedName="intellij.vcs.github.commentComponentFactory"
|
||||
interface="org.jetbrains.plugins.github.pullrequest.ui.editor.GHPRAICommentComponentFactory"
|
||||
dynamic="true"/>
|
||||
|
||||
<extensionPoint qualifiedName="com.intellij.vcs.github.gistContentsCollector"
|
||||
interface="org.jetbrains.plugins.github.GithubGistContentsCollector"
|
||||
dynamic="true"/>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<idea-plugin package="org.jetbrains.plugins.github.ai">
|
||||
<dependencies>
|
||||
<plugin id="com.intellij.ml.llm"/>
|
||||
</dependencies>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<toolWindow id="PR AI Assistant" icon="org.jetbrains.plugins.github.GithubIcons.PullRequestsToolWindow"
|
||||
anchor="right" doNotActivateOnStart="true" canCloseContents="false"
|
||||
factoryClass="org.jetbrains.plugins.github.ai.assistedReview.GHPRAIReviewToolwindowFactory"/>
|
||||
</extensions>
|
||||
|
||||
<extensions defaultExtensionNs="intellij.vcs.github">
|
||||
<commentViewModelProvider implementation="org.jetbrains.plugins.github.ai.assistedReview.GHPRAIReviewCommentViewModelProvider"/>
|
||||
</extensions>
|
||||
|
||||
<actions>
|
||||
<action class="org.jetbrains.plugins.github.ai.assistedReview.GHPRAIReviewOpenAction"
|
||||
internal="true">
|
||||
<add-to-group group-id="Github.PullRequest.StatusChecks.AdditionalActions"/>
|
||||
</action>
|
||||
</actions>
|
||||
</idea-plugin>
|
||||
@@ -1,8 +0,0 @@
|
||||
tab.title.pr.ai.assistant=PR AI Assistant
|
||||
review.buddy.llm.discussion.summarize.display=Summarize the discussion
|
||||
request.ai.review.in.review.toolwindow=Request AI review in Review Toolwindow
|
||||
line=Line {0}:
|
||||
pull.request.summary=Pull Request Summary
|
||||
more.details=More details
|
||||
hide.details=Hide details
|
||||
tab.title.github.review.buddy=GitHub Review Buddy
|
||||
@@ -1,11 +1,14 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.pullrequest.ui.comment
|
||||
package org.jetbrains.plugins.github.ai
|
||||
|
||||
import com.intellij.collaboration.ui.codereview.comment.CodeReviewSubmittableTextViewModel
|
||||
import com.intellij.collaboration.ui.codereview.diff.DiffLineLocation
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.GHPRAICommentChatViewModel
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
|
||||
@ApiStatus.Internal
|
||||
interface GHPRAICommentViewModel {
|
||||
val key: Any
|
||||
val textHtml: StateFlow<String>
|
||||
@@ -28,4 +31,19 @@ interface GHPRAICommentViewModel {
|
||||
* Disacrd the comment
|
||||
*/
|
||||
fun reject()
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
interface GHPRAICommentChatViewModel {
|
||||
val messages: SharedFlow<GHPRAIReviewCommentChatMessage>
|
||||
val newMessageVm: GHPRAIChatNewCommentViewModel
|
||||
|
||||
fun destroy()
|
||||
fun summarize()
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
interface GHPRAIChatNewCommentViewModel : CodeReviewSubmittableTextViewModel {
|
||||
fun submit()
|
||||
fun summarize()
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
package org.jetbrains.plugins.github.ai
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
|
||||
@ApiStatus.Internal
|
||||
interface GHPRAIReviewCommentChatViewModel {
|
||||
/**
|
||||
* Should emit both my messages and chat responses (in MD)
|
||||
@@ -20,4 +22,5 @@ interface GHPRAIReviewCommentChatViewModel {
|
||||
suspend fun summarizeDiscussion()
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
data class GHPRAIReviewCommentChatMessage(val message: String, val isResponse: Boolean)
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai
|
||||
|
||||
import com.intellij.DynamicBundle
|
||||
import org.jetbrains.annotations.Nls
|
||||
import org.jetbrains.annotations.NonNls
|
||||
import org.jetbrains.annotations.NotNull
|
||||
import org.jetbrains.annotations.PropertyKey
|
||||
import java.util.function.Supplier
|
||||
|
||||
object GithubAIBundle {
|
||||
private const val BUNDLE: @NonNls String = "messages.GithubAIBundle"
|
||||
private val INSTANCE: DynamicBundle = DynamicBundle(GithubAIBundle::class.java, BUNDLE)
|
||||
|
||||
fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any?): @Nls String {
|
||||
return INSTANCE.getMessage(key, *params)
|
||||
}
|
||||
|
||||
@NotNull
|
||||
fun messagePointer(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any?): Supplier<String> {
|
||||
return INSTANCE.getLazyMessage(key, *params)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class FileReviewAiResponse(
|
||||
val summary: String,
|
||||
val comments: List<ReviewCommentAiResponse>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NamedFileReviewAiResponse(
|
||||
val filename: String,
|
||||
val summary: String,
|
||||
val comments: List<ReviewCommentAiResponse>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReviewCommentAiResponse(
|
||||
@SerialName("line_number")
|
||||
val lineNumber: Int,
|
||||
val reasoning: String,
|
||||
val comment: String,
|
||||
)
|
||||
|
||||
fun FileReviewAiResponse.sorted() = FileReviewAiResponse(
|
||||
summary,
|
||||
comments.sortedBy { it.lineNumber }
|
||||
)
|
||||
|
||||
fun NamedFileReviewAiResponse.sorted() = NamedFileReviewAiResponse(
|
||||
filename,
|
||||
summary,
|
||||
comments.sortedBy { it.lineNumber }
|
||||
)
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
import com.intellij.collaboration.async.combineState
|
||||
import com.intellij.collaboration.ui.codereview.comment.CodeReviewSubmittableTextViewModelBase
|
||||
import com.intellij.collaboration.ui.codereview.diff.DiffLineLocation
|
||||
import com.intellij.collaboration.util.RefComparisonChange
|
||||
import com.intellij.collaboration.util.SingleCoroutineLauncher
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.platform.util.coroutines.childScope
|
||||
import git4idea.changes.GitTextFilePatchWithHistory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIComment
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.accept
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.discard
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.mapToLocation
|
||||
import org.jetbrains.plugins.github.pullrequest.comment.convertToHtml
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
|
||||
|
||||
class GHPRAIReviewCommentDiffViewModel internal constructor(
|
||||
private val project: Project,
|
||||
parentCs: CoroutineScope,
|
||||
private val reviewVm: GHPRAiAssistantReviewViewModel,
|
||||
private val data: GHPRAIComment,
|
||||
change: RefComparisonChange,
|
||||
diffData: GitTextFilePatchWithHistory,
|
||||
) : GHPRAICommentViewModel {
|
||||
private val cs = parentCs.childScope(javaClass.name)
|
||||
private val taskLauncher = SingleCoroutineLauncher(cs)
|
||||
|
||||
private val dataState = MutableStateFlow(data)
|
||||
|
||||
override val key: Any = data.id
|
||||
|
||||
override val location: DiffLineLocation? = data.position.mapToLocation(change.revisionNumberAfter.asString(), diffData)
|
||||
override val isVisible: StateFlow<Boolean> = data.accepted.combineState(data.rejected) { acc, rej ->
|
||||
!acc && !rej
|
||||
}
|
||||
override val textHtml: StateFlow<String> by lazy {
|
||||
MutableStateFlow(data.textHtml.convertToHtml(project))
|
||||
}
|
||||
|
||||
override val isBusy: StateFlow<Boolean> = taskLauncher.busy
|
||||
|
||||
override val chatVm: MutableStateFlow<GHPRAICommentChatViewModel?> = MutableStateFlow(null)
|
||||
|
||||
override fun startChat() {
|
||||
chatVm.getAndUpdate {
|
||||
GHPRAICommentChatViewModel(project, cs) {
|
||||
reviewVm.startThreadOnComment(data.id)
|
||||
}.also {
|
||||
it.newMessageVm.requestFocus()
|
||||
}
|
||||
}?.destroy()
|
||||
}
|
||||
|
||||
override fun accept() {
|
||||
data.accept()
|
||||
}
|
||||
|
||||
override fun reject() {
|
||||
data.discard()
|
||||
}
|
||||
|
||||
fun update(data: GHPRAIComment) {
|
||||
dataState.value = data
|
||||
}
|
||||
}
|
||||
|
||||
class GHPRAICommentChatViewModel(private val project: Project, parentCs: CoroutineScope, chatCreator: suspend () -> GHPRAIReviewCommentChatViewModel) {
|
||||
private val cs = parentCs.childScope()
|
||||
|
||||
private val chatState: StateFlow<GHPRAIReviewCommentChatViewModel?> = flow {
|
||||
val chat = chatCreator()
|
||||
emit(chat)
|
||||
}.stateIn(cs, SharingStarted.Eagerly, null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val messages: SharedFlow<GHPRAIReviewCommentChatMessage> = chatState.flatMapLatest {
|
||||
it?.messages?.map {
|
||||
it.copy(message = it.message.convertToHtml(project))
|
||||
} ?: flow {}
|
||||
}.shareIn(cs, SharingStarted.Eagerly, Int.MAX_VALUE)
|
||||
val newMessageVm: GHPRAIChatNewCommentViewModel = GHPRAIChatNewCommentViewModel(project, cs, "", chatState)
|
||||
|
||||
fun destroy() {
|
||||
cs.cancel()
|
||||
}
|
||||
|
||||
fun summarize() {
|
||||
newMessageVm.summarize()
|
||||
}
|
||||
}
|
||||
|
||||
class GHPRAIChatNewCommentViewModel(
|
||||
project: Project, cs: CoroutineScope, initialText: String,
|
||||
private val chatState: StateFlow<GHPRAIReviewCommentChatViewModel?>,
|
||||
)
|
||||
: CodeReviewSubmittableTextViewModelBase(project, cs, initialText) {
|
||||
fun submit() {
|
||||
submit { toSubmit ->
|
||||
text.value = ""
|
||||
chatState.value?.sendMessage(toSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
fun summarize() {
|
||||
submit {
|
||||
chatState.value?.summarizeDiscussion()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
import com.intellij.collaboration.util.RefComparisonChange
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import git4idea.changes.GitTextFilePatchWithHistory
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.jetbrains.plugins.github.pullrequest.data.provider.GHPRDataProvider
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.diff.GHPRAICommentViewModelProvider
|
||||
|
||||
class GHPRAIReviewCommentViewModelProvider : GHPRAICommentViewModelProvider {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getComments(
|
||||
project: Project,
|
||||
dataProvider: GHPRDataProvider,
|
||||
change: RefComparisonChange,
|
||||
diffData: GitTextFilePatchWithHistory,
|
||||
): Flow<List<GHPRAICommentViewModel>> =
|
||||
project.service<GHPRAIReviewToolwindowViewModel>().requestedReview
|
||||
.flatMapLatest { reviewVmOrNull ->
|
||||
val reviewVm = reviewVmOrNull ?: return@flatMapLatest flowOf(listOf())
|
||||
reviewVm.getAICommentsForDiff(change, diffData)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
import com.intellij.ml.llm.MLLlmIcons
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.service
|
||||
import org.jetbrains.plugins.github.pullrequest.action.GHPRActionKeys
|
||||
|
||||
class GHPRAIReviewOpenAction : AnAction("Open Review Buddy", null, MLLlmIcons.AiAssistantColored) {
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = false
|
||||
|
||||
e.project ?: return
|
||||
e.getData(GHPRActionKeys.PULL_REQUEST_DATA_PROVIDER) ?: return
|
||||
e.getData(GHPRActionKeys.PULL_REQUEST_REPOSITORY) ?: return
|
||||
|
||||
e.presentation.isEnabledAndVisible = true
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
val dataProvider = e.getData(GHPRActionKeys.PULL_REQUEST_DATA_PROVIDER) ?: return
|
||||
val gitRepository = e.getData(GHPRActionKeys.PULL_REQUEST_REPOSITORY) ?: return
|
||||
|
||||
project.service<GHPRAIReviewToolwindowViewModel>()
|
||||
.requestReview(dataProvider, gitRepository)
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
import com.intellij.collaboration.async.collectScoped
|
||||
import com.intellij.collaboration.async.launchNow
|
||||
import com.intellij.collaboration.ui.*
|
||||
import com.intellij.collaboration.ui.layout.SizeRestrictedSingleComponentLayout
|
||||
import com.intellij.collaboration.ui.util.DimensionRestrictions
|
||||
import com.intellij.collaboration.ui.util.bindIconIn
|
||||
import com.intellij.collaboration.util.RefComparisonChange
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.observable.util.addMouseHoverListener
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.ScrollPaneFactory
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBLayeredPane
|
||||
import com.intellij.ui.hover.HoverListener
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jetbrains.plugins.github.ai.GithubAIBundle
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIComment
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIReview
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIReviewFile
|
||||
import java.awt.*
|
||||
import java.awt.event.ActionListener
|
||||
import javax.swing.*
|
||||
import javax.swing.event.ChangeListener
|
||||
|
||||
object GHPRAIReviewReviewComponentFactory {
|
||||
fun create(scope: CoroutineScope, vm: GHPRAiAssistantReviewViewModel): JComponent {
|
||||
val reviewPanel = VerticalListPanel(10).apply {
|
||||
border = JBUI.Borders.empty(8)
|
||||
}
|
||||
val stickyHeaderComponents = mutableListOf<Pair<JComponent, () -> JComponent>>()
|
||||
val isLoading = MutableStateFlow(true)
|
||||
scope.launchNow(Dispatchers.EDT) {
|
||||
isLoading.collect {
|
||||
if (it) {
|
||||
reviewPanel.removeAll()
|
||||
reviewPanel.add(LoadingTextLabel())
|
||||
}
|
||||
reviewPanel.revalidate()
|
||||
reviewPanel.repaint()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launchNow(Dispatchers.EDT) {
|
||||
vm.state.collectScoped { review ->
|
||||
val cs = this
|
||||
if (review == null) {
|
||||
return@collectScoped
|
||||
}
|
||||
isLoading.value = false
|
||||
reviewPanel.removeAll()
|
||||
stickyHeaderComponents.clear()
|
||||
/*when (it) {
|
||||
is AiReviewSummaryReceived -> {
|
||||
reviewPanel.add(createAiResponseComponent(vm, it.summary, emptyList(), stickyHeaderComponents))
|
||||
reviewPanel.add(JLabel("Loading comments..."))
|
||||
}
|
||||
is AiReviewCompleted -> {
|
||||
reviewPanel.add(createAiResponseComponent(vm, it.summary, it.sortedFilesResponse, stickyHeaderComponents))
|
||||
}
|
||||
is AiReviewFailed -> {
|
||||
reviewPanel.add(JLabel("Error during AI review: ${it.error}"))
|
||||
}
|
||||
else -> {
|
||||
error("Invalid code path")
|
||||
}
|
||||
}*/
|
||||
reviewPanel.add(cs.createAiResponseComponent(vm, review, stickyHeaderComponents))
|
||||
reviewPanel.revalidate()
|
||||
reviewPanel.repaint()
|
||||
}
|
||||
}
|
||||
|
||||
return withStickyHeader(
|
||||
ScrollPaneFactory.createScrollPane(reviewPanel, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER),
|
||||
stickyHeaderComponents
|
||||
)
|
||||
}
|
||||
|
||||
private fun withStickyHeader(
|
||||
scrollPane: JScrollPane,
|
||||
componentsToCheckViewport: List<Pair<JComponent, () -> JComponent>>,
|
||||
): JComponent {
|
||||
val stickyPanel = BorderLayoutPanel().apply {
|
||||
border = JBUI.Borders.empty(5, 8, 0, 8)
|
||||
}
|
||||
val viewPortListener = ChangeListener {
|
||||
val result = componentsToCheckViewport.lastOrNull { (component, _) ->
|
||||
component.bounds.y < scrollPane.viewport.viewPosition.y
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
val provider = result.second
|
||||
stickyPanel.removeAll()
|
||||
val content = provider()
|
||||
stickyPanel.addToCenter(content)
|
||||
stickyPanel.setBounds(0, 0, scrollPane.width - JBUI.scale(12), JBUI.scale(35))
|
||||
}
|
||||
else {
|
||||
stickyPanel.setBounds(0, 0, 0, 0)
|
||||
stickyPanel.removeAll()
|
||||
}
|
||||
stickyPanel.revalidate()
|
||||
stickyPanel.repaint()
|
||||
}
|
||||
|
||||
scrollPane.viewport.addChangeListener(viewPortListener)
|
||||
|
||||
val contentPanel: JComponent = object : JBLayeredPane() {
|
||||
override fun getPreferredSize(): Dimension = scrollPane.preferredSize
|
||||
|
||||
override fun doLayout() {
|
||||
scrollPane.setBounds(0, 0, width, height)
|
||||
}
|
||||
}.apply {
|
||||
isFocusable = false
|
||||
add(scrollPane, JLayeredPane.DEFAULT_LAYER, 0)
|
||||
add(stickyPanel, JLayeredPane.POPUP_LAYER, 1)
|
||||
}
|
||||
|
||||
return contentPanel
|
||||
}
|
||||
|
||||
private fun CoroutineScope.createAiResponseComponent(vm: GHPRAiAssistantReviewViewModel,
|
||||
review: GHPRAIReview,
|
||||
stickyHeaderComponents: MutableList<Pair<JComponent, () -> JComponent>>): JComponent {
|
||||
return VerticalListPanel(5).apply {
|
||||
add(createSummaryComponent(review.ideaHtml, review.summaryHtml))
|
||||
val files = review.files
|
||||
for (fileReview in files) {
|
||||
val fileReviewComponent = createFileReviewComponent(vm, fileReview) ?: continue
|
||||
stickyHeaderComponents.add(fileReviewComponent to {
|
||||
createSingleFileComponent(vm, fileReview)
|
||||
})
|
||||
add(fileReviewComponent)
|
||||
}
|
||||
if (!review.reviewCompleted) {
|
||||
add(LoadingTextLabel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.createSummaryComponent(idea: String, summary: String?): JComponent {
|
||||
return VerticalListPanel(5).apply {
|
||||
border = JBUI.Borders.empty(10)
|
||||
add(JLabel(GithubAIBundle.message("pull.request.summary")).apply {
|
||||
font = font.deriveFont(Font.BOLD, 16f)
|
||||
})
|
||||
add(SimpleHtmlPane(idea))
|
||||
|
||||
if(summary != null){
|
||||
val collapsed = MutableStateFlow(true)
|
||||
val link = ActionLink("") {
|
||||
collapsed.update { !it }
|
||||
}
|
||||
val summaryPane = SimpleHtmlPane(summary)
|
||||
|
||||
launchNow {
|
||||
collapsed.collect {
|
||||
if (it) {
|
||||
link.text = GithubAIBundle.message("more.details")
|
||||
link.setIcon(AllIcons.Actions.ArrowExpand, true)
|
||||
summaryPane.isVisible = false
|
||||
}
|
||||
else {
|
||||
link.text = GithubAIBundle.message("hide.details")
|
||||
link.setIcon(AllIcons.Actions.ArrowCollapse, true)
|
||||
summaryPane.isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add(link)
|
||||
add(summaryPane)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.createFileReviewComponent(vm: GHPRAiAssistantReviewViewModel, file: GHPRAIReviewFile): JComponent {
|
||||
return VerticalListPanel(5).apply {
|
||||
add(createSingleFileComponent(vm, file))
|
||||
file.comments.let {
|
||||
createFileHighlightsComponent(vm, file, it)
|
||||
}.also(::add)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSingleFileComponent(vm: GHPRAiAssistantReviewViewModel, file: GHPRAIReviewFile): JComponent {
|
||||
val singleFilePanelContent = BorderLayoutPanel().apply {
|
||||
border = JBUI.Borders.empty(5, 10)
|
||||
background = JBColor(0xEBECF0, 0x494B57)
|
||||
val fileLink = ActionLink(file.req.path.name, ActionListener {
|
||||
vm.showDiffFor(file.req.changeToNavigate)
|
||||
}).apply {
|
||||
foreground = UIUtil.getLabelForeground()
|
||||
setIcon(file.req.path.fileType.icon)
|
||||
minimumSize = Dimension(0, 0)
|
||||
}
|
||||
addToCenter(fileLink)
|
||||
}
|
||||
|
||||
val singleFilePanel = ClippingRoundedPanel(arcRadius = 5, layoutManager = BorderLayout()).apply {
|
||||
add(singleFilePanelContent, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
singleFilePanel.addMouseHoverListener(null, object : HoverListener() {
|
||||
override fun mouseExited(component: Component) {
|
||||
singleFilePanelContent.background = JBColor(0xEBECF0, 0x494B57)
|
||||
singleFilePanelContent.repaint()
|
||||
}
|
||||
|
||||
override fun mouseMoved(component: Component, x: Int, y: Int) {
|
||||
}
|
||||
|
||||
override fun mouseEntered(component: Component, x: Int, y: Int) {
|
||||
singleFilePanelContent.background = Color(223, 225, 229)
|
||||
singleFilePanelContent.repaint()
|
||||
}
|
||||
})
|
||||
|
||||
return singleFilePanel
|
||||
}
|
||||
|
||||
private fun CoroutineScope.createFileHighlightsComponent(vm: GHPRAiAssistantReviewViewModel, file: GHPRAIReviewFile, highlights: List<GHPRAIComment>): JComponent {
|
||||
return VerticalListPanel(5).apply {
|
||||
border = JBUI.Borders.emptyLeft(5)
|
||||
for (highlight in highlights) {
|
||||
add(createAiCommentComponent(vm, file.req.changeToNavigate, highlight))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.createAiCommentComponent(vm: GHPRAiAssistantReviewViewModel, change: RefComparisonChange, comment: GHPRAIComment): JComponent {
|
||||
val cs = this
|
||||
val icon = JBLabel(AllIcons.General.InspectionsEye).apply {
|
||||
bindIconIn(cs, comment.accepted.combine(comment.rejected) { acc, rej ->
|
||||
if (acc) {
|
||||
AllIcons.General.InspectionsOK
|
||||
}
|
||||
else if (rej) {
|
||||
AllIcons.General.InspectionsPause
|
||||
}
|
||||
else {
|
||||
AllIcons.General.InspectionsEye
|
||||
}
|
||||
})
|
||||
}
|
||||
val lineLink = ActionLink(GithubAIBundle.message("line", comment.position.lineIndex)).apply {
|
||||
addActionListener {
|
||||
vm.showDiffFor(change, comment.position.lineIndex)
|
||||
}
|
||||
}
|
||||
val commentPanelL = SizeRestrictedSingleComponentLayout()
|
||||
val commentPane = SimpleHtmlPane(comment.textHtml)
|
||||
val commentPanel = JPanel(commentPanelL).apply {
|
||||
isOpaque = false
|
||||
add(commentPane)
|
||||
}
|
||||
|
||||
val iconAndLine = JPanel(BorderLayout()).apply {
|
||||
add(
|
||||
HorizontalListPanel(5).apply {
|
||||
add(icon)
|
||||
add(lineLink)
|
||||
},
|
||||
BorderLayout.NORTH
|
||||
)
|
||||
}
|
||||
|
||||
val reasonComponent = SimpleHtmlPane(comment.reasoningHtml).apply {
|
||||
foreground = UIUtil.getContextHelpForeground()
|
||||
}
|
||||
|
||||
cs.launchNow {
|
||||
comment.accepted.combine(comment.rejected) { acc, rej -> acc || rej }.collect { hide ->
|
||||
reasonComponent.isVisible = !hide
|
||||
commentPanelL.maxSize =
|
||||
if (hide) DimensionRestrictions.LinesHeight(commentPanel, 1)
|
||||
else DimensionRestrictions.None
|
||||
commentPane.foreground = if (hide) UIUtil.getContextHelpForeground() else UIUtil.getLabelForeground()
|
||||
}
|
||||
}
|
||||
|
||||
return BorderLayoutPanel().apply {
|
||||
addToLeft(iconAndLine)
|
||||
addToCenter(VerticalListPanel(5).apply {
|
||||
border = JBUI.Borders.emptyLeft(5)
|
||||
add(commentPanel)
|
||||
add(reasonComponent)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
import ai.grazie.model.llm.chat.v5.LLMChat
|
||||
import ai.grazie.model.task.data.TaskStreamData
|
||||
import com.intellij.collaboration.async.mapDataToModel
|
||||
import com.intellij.collaboration.async.nestedDisposable
|
||||
import com.intellij.collaboration.async.stateInNow
|
||||
import com.intellij.collaboration.async.withInitial
|
||||
import com.intellij.collaboration.ui.codereview.diff.DiffLineLocation
|
||||
import com.intellij.collaboration.util.ChangesSelection
|
||||
import com.intellij.collaboration.util.RefComparisonChange
|
||||
import com.intellij.collaboration.util.filePath
|
||||
import com.intellij.diff.util.Side
|
||||
import com.intellij.ml.llm.core.grazieAPI.GrazieApiClient
|
||||
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.PrivacySafeTaskCall
|
||||
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddyDiscussionCommentTaskCallBuilder
|
||||
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddyReviewAllFilesTaskCallBuilder
|
||||
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddyReviewFileTaskCallBuilder
|
||||
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddySummarizeDiscussionTaskCallBuilder
|
||||
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddySummarizeTaskCallBuilder
|
||||
import com.intellij.openapi.components.serviceAsync
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.progress.coroutineToIndicator
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vcs.FilePath
|
||||
import com.intellij.platform.util.coroutines.childScope
|
||||
import git4idea.changes.GitTextFilePatchWithHistory
|
||||
import git4idea.changes.createVcsChange
|
||||
import git4idea.repo.GitRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jetbrains.plugins.github.ai.GithubAIBundle
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.llm.waitForCompletion
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.AIComment
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.AIFileReviewResponse
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.AIFileReviewsPartiallyReceived
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewCompleted
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewFailed
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewRequested
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewResponse
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewResponseState
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewSummaryReceived
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIComment
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAICommentPosition
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIReview
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIReviewFile
|
||||
import org.jetbrains.plugins.github.ai.assistedReview.model.ReviewFileAIData
|
||||
import org.jetbrains.plugins.github.pullrequest.comment.convertToHtml
|
||||
import org.jetbrains.plugins.github.pullrequest.data.provider.GHPRDataProvider
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.toolwindow.model.GHPRToolWindowViewModel
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.collections.forEach
|
||||
import kotlin.collections.set
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Represents the results of an AI-reviewed PR.
|
||||
*/
|
||||
class GHPRAiAssistantReviewViewModel internal constructor(
|
||||
private val project: Project,
|
||||
private val cs: CoroutineScope,
|
||||
private val dataProvider: GHPRDataProvider,
|
||||
private val gitRepository: GitRepository,
|
||||
) {
|
||||
private val prId = dataProvider.id
|
||||
private val changesData = dataProvider.changesData
|
||||
|
||||
val state: MutableStateFlow<GHPRAIReview?> = MutableStateFlow(null)
|
||||
|
||||
private val commentChatInitialChats = ConcurrentHashMap<AIComment, LLMChat>()
|
||||
private val commentChats = ConcurrentHashMap<AIComment, GHPRAIReviewCommentChatViewModel>()
|
||||
|
||||
private val grazieClient = GrazieApiClient.getClient()
|
||||
|
||||
init {
|
||||
loadReview()
|
||||
}
|
||||
|
||||
fun showDiffFor(change: RefComparisonChange, line: Int? = null) {
|
||||
cs.launch(Dispatchers.Main) {
|
||||
val projectVm = project.serviceAsync<GHPRToolWindowViewModel>().projectVm.value ?: return@launch
|
||||
projectVm.openPullRequestDiff(prId, true)
|
||||
val changes = dataProvider.changesData.loadChanges().changes
|
||||
val location = line?.let { DiffLineLocation(Side.RIGHT, it) }
|
||||
val selection = ChangesSelection.Precise(changes, change, location)
|
||||
|
||||
val disposable = nestedDisposable()
|
||||
projectVm.acquireDiffViewModel(prId, disposable).showDiffFor(selection)
|
||||
}
|
||||
}
|
||||
|
||||
fun startThreadOnComment(comment: AIComment): GHPRAIReviewCommentChatViewModel {
|
||||
val initialChat = commentChatInitialChats[comment] ?: error("Initial chat comment context not found")
|
||||
return commentChats.getOrPut(comment) { AiAssistantReviewCommentChat(comment, initialChat) }
|
||||
}
|
||||
|
||||
private fun startReview(
|
||||
files: List<ReviewFileAIData>,
|
||||
runQueryPerFile: Boolean = true,
|
||||
): AIReviewResponse {
|
||||
val state = MutableStateFlow<AIReviewResponseState>(AIReviewRequested)
|
||||
val reviewRequestScope = cs.childScope("Review requested")
|
||||
|
||||
reviewRequestScope.launch {
|
||||
val reviewSummaryCompletableMessage = getReviewSummary(files)
|
||||
|
||||
val summary: String
|
||||
try {
|
||||
summary = reviewSummaryCompletableMessage.waitForCompletion()
|
||||
}
|
||||
catch (_: CancellationException) {
|
||||
state.emit(AIReviewFailed(error = "Cancelled"))
|
||||
return@launch
|
||||
}
|
||||
catch (e: Exception) {
|
||||
state.emit(AIReviewFailed(error = e.localizedMessage))
|
||||
return@launch
|
||||
}
|
||||
|
||||
state.emit(AIReviewSummaryReceived(summary = summary))
|
||||
reviewRequestScope.launch {
|
||||
val fileReviews = if (runQueryPerFile) {
|
||||
val reviewedFiles = mutableListOf<AIFileReviewResponse>()
|
||||
files.forEach { file ->
|
||||
reviewedFiles.add(getFileReview(summary, file))
|
||||
state.emit(AIFileReviewsPartiallyReceived(summary, reviewedFiles.toList()))
|
||||
}
|
||||
reviewedFiles
|
||||
}
|
||||
else {
|
||||
getAllFileReviews(summary, files)
|
||||
}
|
||||
state.emit(AIReviewCompleted(summary, fileReviews))
|
||||
}
|
||||
|
||||
throw CancellationException("Summary received")
|
||||
}
|
||||
|
||||
return AIReviewResponse(state)
|
||||
}
|
||||
|
||||
private suspend fun getReviewSummary(files: List<ReviewFileAIData>): Flow<TaskStreamData> {
|
||||
val task = ReviewBuddySummarizeTaskCallBuilder(files.map { it.toTaskData() }).build()
|
||||
return grazieClient.sendTaskRequest(project, task) ?: error("Failed to send task")
|
||||
}
|
||||
|
||||
private suspend fun getFileReview(summary: String, file: ReviewFileAIData): AIFileReviewResponse {
|
||||
val taskBuilder = ReviewBuddyReviewFileTaskCallBuilder(summary, file.toTaskData())
|
||||
val task = taskBuilder.build()
|
||||
|
||||
val response = grazieClient.sendTaskRequest(project, task)?.waitForCompletion() ?: error("Failed to send task")
|
||||
val jsonResponse = extractJsonFromResponse(response)
|
||||
val parsedResponse = try {
|
||||
parseFileReview(jsonResponse)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
thisLogger().warn("Failed to parse JSON response: $jsonResponse", e)
|
||||
FileReviewAiResponse(summary = "Failed to parse JSON response: ${e.localizedMessage}", comments = emptyList())
|
||||
}
|
||||
|
||||
val chat = LLMChat.build {
|
||||
messages(taskBuilder.asChat())
|
||||
assistant(response)
|
||||
}
|
||||
val aiComments = parsedResponse.comments.map { it.toModelComment() }
|
||||
aiComments.forEach { commentChatInitialChats[it] = chat }
|
||||
|
||||
return AIFileReviewResponse(
|
||||
file.rawLocalPath,
|
||||
parsedResponse.summary,
|
||||
highlights = emptyList(),
|
||||
comments = aiComments
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getAllFileReviews(summary: String, files: List<ReviewFileAIData>): List<AIFileReviewResponse> {
|
||||
val task = ReviewBuddyReviewAllFilesTaskCallBuilder(summary, files.map { it.toTaskData() }).build()
|
||||
|
||||
val response = grazieClient.sendTaskRequest(project, task) ?: error("Failed to send task")
|
||||
val jsonResponse = extractJsonFromResponse(response.waitForCompletion())
|
||||
val parsedResponse = try {
|
||||
parseAllFileReviews(jsonResponse)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
thisLogger().warn("Failed to parse JSON response: $jsonResponse", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return parsedResponse.map { fileResponse ->
|
||||
AIFileReviewResponse(
|
||||
fileResponse.filename,
|
||||
fileResponse.summary,
|
||||
highlights = emptyList(),
|
||||
comments = fileResponse.comments.map { it.toModelComment() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFileReview(jsonResponse: String): FileReviewAiResponse =
|
||||
Json.decodeFromString<FileReviewAiResponse>(jsonResponse).sorted()
|
||||
|
||||
private fun parseAllFileReviews(jsonResponse: String): List<NamedFileReviewAiResponse> =
|
||||
Json.decodeFromString<List<NamedFileReviewAiResponse>>(jsonResponse).map { it.sorted() }
|
||||
|
||||
private fun ReviewCommentAiResponse.toModelComment(): AIComment =
|
||||
AIComment(lineNumber, reasoning, comment)
|
||||
|
||||
val comments: StateFlow<Collection<GHPRAIComment>?> =
|
||||
state.map {
|
||||
it?.files?.map { it.comments }?.flatten().orEmpty()
|
||||
}.stateInNow(cs, emptyList())
|
||||
|
||||
fun getAICommentsForDiff(change: RefComparisonChange, diffData: GitTextFilePatchWithHistory): Flow<List<GHPRAIReviewCommentDiffViewModel>> =
|
||||
state
|
||||
.map { st -> st?.files?.find { it.req.rawLocalPath == change.filePath.let(::toLocalPath) }?.comments.orEmpty() }
|
||||
.mapDataToModel({ it.id }, { createAiComment(it, change, diffData) }, { update(it) })
|
||||
|
||||
private fun CoroutineScope.createAiComment(comment: GHPRAIComment, change: RefComparisonChange, diffData: GitTextFilePatchWithHistory): GHPRAIReviewCommentDiffViewModel =
|
||||
GHPRAIReviewCommentDiffViewModel(project, this@createAiComment, this@GHPRAiAssistantReviewViewModel, comment, change, diffData)
|
||||
|
||||
fun toLocalPath(filePath: FilePath): String =
|
||||
filePath.path.removePrefix(gitRepository.root.path).removePrefix("/")
|
||||
|
||||
private fun loadReview() {
|
||||
cs.launch {
|
||||
changesData.changesNeedReloadSignal.withInitial(Unit).collectLatest {
|
||||
val changes = changesData.loadChanges().changes
|
||||
changesData.ensureAllRevisionsFetched()
|
||||
val files = changes.mapNotNull {
|
||||
val filePath = it.filePathAfter ?: return@mapNotNull null
|
||||
val localFilePath = toLocalPath(filePath)
|
||||
val (before, after) = withContext(Dispatchers.IO) {
|
||||
coroutineToIndicator {
|
||||
val change = it.createVcsChange(project)
|
||||
change.beforeRevision?.content to change.afterRevision?.content
|
||||
}
|
||||
}
|
||||
ReviewFileAIData(filePath, localFilePath, it, before, after)
|
||||
}
|
||||
|
||||
val response = startReview(files)
|
||||
response.state.collect {
|
||||
when (it) {
|
||||
AIReviewRequested -> Unit
|
||||
is AIReviewSummaryReceived -> {
|
||||
val (idea, sum) = splitSummary(it.summary)
|
||||
state.value = GHPRAIReview(idea.convertToHtml(project), sum?.convertToHtml(project), reviewCompleted = false)
|
||||
}
|
||||
is AIFileReviewsPartiallyReceived -> {
|
||||
val (idea, sum) = splitSummary(it.summary)
|
||||
val filesRes = buildViewModel(it.reviewedFiles, files)
|
||||
state.value = GHPRAIReview(idea.convertToHtml(project), sum?.convertToHtml(project), filesRes, reviewCompleted = false)
|
||||
}
|
||||
is AIReviewCompleted -> {
|
||||
val (idea, sum) = splitSummary(it.summary)
|
||||
val sortedFilesResponse = it.sortedFilesResponse
|
||||
val filesRes = buildViewModel(sortedFilesResponse, files)
|
||||
state.value = GHPRAIReview(idea.convertToHtml(project), sum?.convertToHtml(project), filesRes, reviewCompleted = true)
|
||||
}
|
||||
is AIReviewFailed -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store already created [GHPRAIReviewFile] between [buildViewModel] calls.
|
||||
*/
|
||||
private val alreadyBuiltFileReviews = ConcurrentHashMap<AIFileReviewResponse, GHPRAIReviewFile>()
|
||||
|
||||
private fun buildViewModel(
|
||||
fileReviewResponse: List<AIFileReviewResponse>,
|
||||
files: List<ReviewFileAIData>,
|
||||
): List<GHPRAIReviewFile> {
|
||||
val filesRes = fileReviewResponse.mapNotNull { fileReview ->
|
||||
val req = files.find { it.rawLocalPath == fileReview.file } ?: return@mapNotNull null
|
||||
alreadyBuiltFileReviews.getOrPut(fileReview) { buildReviewFile(fileReview, req) }
|
||||
}
|
||||
return filesRes
|
||||
}
|
||||
|
||||
private fun buildReviewFile(fileReview: AIFileReviewResponse, req: ReviewFileAIData): GHPRAIReviewFile {
|
||||
val comments = fileReview.comments.map { comment ->
|
||||
GHPRAIComment(
|
||||
comment,
|
||||
GHPRAICommentPosition(fileReview.file, comment.lineNumber - 1),
|
||||
comment.comment.convertToHtml(project),
|
||||
comment.reasoning.convertToHtml(project),
|
||||
comment.comment,
|
||||
comment.reasoning
|
||||
)
|
||||
}
|
||||
return GHPRAIReviewFile(fileReview.file, fileReview.summary, comments, req)
|
||||
}
|
||||
|
||||
private fun splitSummary(summary: String): Pair<String, String?> =
|
||||
summary.removePrefix("## Pull Request Summary").split("## Main idea").let {
|
||||
if (it.size < 2) return it.first().trim() to null else it[1].trim() to it.first().trim()
|
||||
}
|
||||
|
||||
private inner class AiAssistantReviewCommentChat(
|
||||
private val aiComment: AIComment,
|
||||
initialChat: LLMChat,
|
||||
) : GHPRAIReviewCommentChatViewModel {
|
||||
private var chat = initialChat
|
||||
override val messages: MutableSharedFlow<GHPRAIReviewCommentChatMessage> = MutableSharedFlow(replay = Int.MAX_VALUE)
|
||||
|
||||
override suspend fun sendMessage(message: String) {
|
||||
val task = ReviewBuddyDiscussionCommentTaskCallBuilder(aiComment.lineNumber, message, chat).build()
|
||||
processDiscussionMessage(message, task)
|
||||
}
|
||||
|
||||
override suspend fun summarizeDiscussion() {
|
||||
val message = GithubAIBundle.message("review.buddy.llm.discussion.summarize.display")
|
||||
val task = ReviewBuddySummarizeDiscussionTaskCallBuilder(chat).build()
|
||||
processDiscussionMessage(message, task)
|
||||
}
|
||||
|
||||
private suspend fun processDiscussionMessage(message: String, task: PrivacySafeTaskCall) {
|
||||
messages.emit(GHPRAIReviewCommentChatMessage(message, isResponse = false))
|
||||
val response = grazieClient.sendTaskRequest(project, task) ?: error("Failed to send task")
|
||||
|
||||
cs.childScope("AI assistant response").launch {
|
||||
val response = response.waitForCompletion()
|
||||
messages.emit(GHPRAIReviewCommentChatMessage(response, isResponse = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.plugins.github.ai.GithubAIBundle
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JLabel
|
||||
|
||||
object GHPRAIReviewToolwindowComponentFactory {
|
||||
fun create(scope: CoroutineScope, vm: GHPRAIReviewToolwindowViewModel): JComponent {
|
||||
val panel = BorderLayoutPanel().apply {
|
||||
add(JLabel(GithubAIBundle.message("request.ai.review.in.review.toolwindow")))
|
||||
}
|
||||
scope.launch {
|
||||
vm.requestedReview.collect {
|
||||
withContext(Dispatchers.EDT) {
|
||||
panel.removeAll()
|
||||
if (it != null) {
|
||||
panel.addToCenter(GHPRAIReviewReviewComponentFactory.create(scope, it))
|
||||
}
|
||||
panel.revalidate()
|
||||
panel.repaint()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return panel
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
import com.intellij.collaboration.ui.toolwindow.dontHideOnEmptyContent
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.components.serviceAsync
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.wm.ToolWindow
|
||||
import com.intellij.openapi.wm.ToolWindowFactory
|
||||
import com.intellij.openapi.wm.ToolWindowManager
|
||||
import com.intellij.openapi.wm.impl.content.ToolWindowContentUi
|
||||
import com.intellij.platform.util.coroutines.childScope
|
||||
import com.intellij.ui.content.ContentFactory
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import kotlinx.coroutines.*
|
||||
import org.jetbrains.plugins.github.ai.GithubAIBundle
|
||||
|
||||
internal class GHPRAIReviewToolwindowFactory : ToolWindowFactory, DumbAware {
|
||||
override fun init(toolWindow: ToolWindow) {
|
||||
toolWindow.setStripeShortTitleProvider(GithubAIBundle.messagePointer("tab.title.pr.ai.assistant"))
|
||||
}
|
||||
|
||||
override suspend fun manage(toolWindow: ToolWindow, toolWindowManager: ToolWindowManager) {
|
||||
toolWindow.project.serviceAsync<GHPRAIReviewToolwindowController>().manageIconInToolbar(toolWindow)
|
||||
}
|
||||
|
||||
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
|
||||
project.service<GHPRAIReviewToolwindowController>().manageContent(toolWindow)
|
||||
}
|
||||
|
||||
override fun shouldBeAvailable(project: Project): Boolean =
|
||||
ApplicationManager.getApplication().isInternal
|
||||
}
|
||||
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
private class GHPRAIReviewToolwindowController(private val project: Project, parentCs: CoroutineScope) {
|
||||
private val cs = parentCs.childScope(Dispatchers.Main)
|
||||
|
||||
suspend fun manageIconInToolbar(toolWindow: ToolWindow) {
|
||||
coroutineScope {
|
||||
val vm = project.serviceAsync<GHPRAIReviewToolwindowViewModel>()
|
||||
|
||||
launch {
|
||||
vm.activationRequests.collect {
|
||||
withContext(Dispatchers.EDT) {
|
||||
toolWindow.activate(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun manageContent(toolWindow: ToolWindow) {
|
||||
toolWindow.component.putClientProperty(ToolWindowContentUi.HIDE_ID_LABEL, "true")
|
||||
|
||||
// so it's not closed when all content is removed
|
||||
cs.launch {
|
||||
val vm = project.serviceAsync<GHPRAIReviewToolwindowViewModel>()
|
||||
toolWindow.dontHideOnEmptyContent()
|
||||
val displayName = GithubAIBundle.message("tab.title.github.review.buddy")
|
||||
val component = GHPRAIReviewToolwindowComponentFactory.create(cs, vm)
|
||||
toolWindow.contentManager.addContent(ContentFactory.getInstance().createContent(component, displayName, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.platform.util.coroutines.childScope
|
||||
import git4idea.repo.GitRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.plugins.github.pullrequest.data.provider.GHPRDataProvider
|
||||
|
||||
/**
|
||||
* Represents the whole right-side tool window displaying AI assisted review results.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
class GHPRAIReviewToolwindowViewModel(
|
||||
private val project: Project, parentCs: CoroutineScope,
|
||||
) {
|
||||
private val cs = parentCs.childScope(Dispatchers.Main)
|
||||
|
||||
private val _activationRequests = MutableSharedFlow<Unit>(1)
|
||||
internal val activationRequests: Flow<Unit> = _activationRequests.asSharedFlow()
|
||||
|
||||
private val _requestedReview = MutableSharedFlow<GHPRAiAssistantReviewViewModel>(1)
|
||||
internal val requestedReview: StateFlow<GHPRAiAssistantReviewViewModel?> = _requestedReview.stateIn(cs, Eagerly, null)
|
||||
|
||||
fun activate() {
|
||||
_activationRequests.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun requestReview(dataProvider: GHPRDataProvider, gitRepository: GitRepository) {
|
||||
activate()
|
||||
cs.launch {
|
||||
_requestedReview.emit(GHPRAiAssistantReviewViewModel(project, cs, dataProvider, gitRepository))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview
|
||||
|
||||
fun extractJsonFromResponse(response: String): String {
|
||||
val extractJsonRegex = Regex("```json\n([\\s\\S]*?)\n```")
|
||||
val matches = extractJsonRegex.findAll(response)
|
||||
val jsonString = matches.firstOrNull()?.value ?: response
|
||||
val openSymbol = jsonString.indexOfFirst { it == '[' || it == '{' }
|
||||
val closeSymbol = jsonString.indexOfLast { it == ']' || it == '}' }
|
||||
if (openSymbol != -1 && closeSymbol != -1) {
|
||||
return jsonString.substring(openSymbol, closeSymbol + 1)
|
||||
}
|
||||
return jsonString
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview.llm
|
||||
|
||||
import ai.grazie.model.task.data.TaskStreamData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
suspend fun Flow<TaskStreamData>.waitForCompletion(): String {
|
||||
if (this is SharedFlow) {
|
||||
error("Cannot wait for completion of a hot flow. It will never complete.")
|
||||
}
|
||||
|
||||
val result = StringBuilder()
|
||||
collect { result.append(it.content) }
|
||||
|
||||
return result.toString()
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview.model
|
||||
|
||||
import com.intellij.collaboration.util.RefComparisonChange
|
||||
import com.intellij.ml.llm.core.grazieAPI.tasks.vcs.reviewBuddy.ReviewBuddyFileData
|
||||
import com.intellij.openapi.vcs.FilePath
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class ReviewFileAIData(
|
||||
val path: FilePath,
|
||||
val rawLocalPath: String,
|
||||
val changeToNavigate: RefComparisonChange,
|
||||
val contentBefore: String?,
|
||||
val contentAfter: String?
|
||||
) {
|
||||
fun toTaskData() =
|
||||
ReviewBuddyFileData(rawLocalPath, contentBefore, contentAfter)
|
||||
}
|
||||
|
||||
sealed interface AIReviewResponseState
|
||||
|
||||
data object AIReviewRequested : AIReviewResponseState
|
||||
|
||||
data class AIReviewSummaryReceived(val summary: String) : AIReviewResponseState
|
||||
|
||||
data class AIFileReviewsPartiallyReceived(
|
||||
val summary: String,
|
||||
val reviewedFiles: List<AIFileReviewResponse>
|
||||
) : AIReviewResponseState
|
||||
|
||||
data class AIReviewCompleted(
|
||||
val summary: String,
|
||||
val sortedFilesResponse: List<AIFileReviewResponse>
|
||||
) : AIReviewResponseState
|
||||
|
||||
data class AIReviewFailed(
|
||||
val error: String
|
||||
) : AIReviewResponseState
|
||||
|
||||
class AIReviewResponse(
|
||||
/**
|
||||
* The normal flow is: [AIReviewRequested] → [AIReviewSummaryReceived] → [AIReviewCompleted].
|
||||
*
|
||||
* [AIReviewFailed] might occur after each state except after [AIReviewCompleted].
|
||||
*/
|
||||
val state: StateFlow<AIReviewResponseState>
|
||||
)
|
||||
|
||||
data class AIFileReviewResponse(
|
||||
val file: String,
|
||||
val summary: String,
|
||||
val highlights: List<AIComment>,
|
||||
val comments: List<AIComment>,
|
||||
)
|
||||
|
||||
data class AIComment(
|
||||
val lineNumber: Int,
|
||||
val reasoning: String,
|
||||
val comment: String,
|
||||
)
|
||||
|
||||
data class GHPRAIReview(val ideaHtml: String,
|
||||
val summaryHtml: String?,
|
||||
val files: List<GHPRAIReviewFile> = emptyList(),
|
||||
val reviewCompleted: Boolean)
|
||||
|
||||
data class GHPRAIReviewFile(val file: String,
|
||||
val summary: String,
|
||||
val comments: List<GHPRAIComment>,
|
||||
val req: ReviewFileAIData
|
||||
)
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.ai.assistedReview.model
|
||||
|
||||
import com.intellij.collaboration.ui.codereview.diff.DiffLineLocation
|
||||
import com.intellij.diff.util.Side
|
||||
import git4idea.changes.GitTextFilePatchWithHistory
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
data class GHPRAIComment(
|
||||
val id: AIComment,
|
||||
val position: GHPRAICommentPosition,
|
||||
val textHtml: String,
|
||||
val reasoningHtml: String,
|
||||
val text: String,
|
||||
val reasoning: String
|
||||
) {
|
||||
val accepted: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
val rejected: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
}
|
||||
|
||||
fun GHPRAIComment.discard() {
|
||||
accepted.value = false
|
||||
rejected.value = true
|
||||
}
|
||||
|
||||
fun GHPRAIComment.accept() {
|
||||
accepted.value = true
|
||||
rejected.value = false
|
||||
}
|
||||
|
||||
fun GHPRAICommentPosition.mapToLocation(commitSha: String, diffData: GitTextFilePatchWithHistory, sideBias: Side? = null): DiffLineLocation? {
|
||||
val commentData = this
|
||||
|
||||
if (!diffData.contains(commitSha, commentData.path)) return null
|
||||
return diffData.mapLine(commitSha, commentData.lineIndex, sideBias ?: Side.RIGHT)
|
||||
}
|
||||
|
||||
data class GHPRAICommentPosition(val path: String, val lineIndex: Int)
|
||||
@@ -19,6 +19,7 @@ import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
|
||||
import org.intellij.markdown.html.GeneratingProvider
|
||||
import org.intellij.markdown.html.HtmlGenerator
|
||||
import org.intellij.markdown.parser.LinkMap
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import org.jetbrains.annotations.NonNls
|
||||
import java.net.URI
|
||||
|
||||
@@ -98,7 +99,8 @@ class GHMarkdownToHtmlConverter(private val project: Project?) {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun String.convertToHtml(project: Project): @NlsSafe String {
|
||||
@ApiStatus.Internal
|
||||
fun String.convertToHtml(project: Project): @NlsSafe String {
|
||||
val processedText = processIssueIdsMarkdown(project, this)
|
||||
return GHMarkdownToHtmlConverter(project).convertMarkdown(processedText)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import org.jetbrains.plugins.github.api.data.pullrequest.mapToLocation
|
||||
import org.jetbrains.plugins.github.pullrequest.data.GHPRDataContext
|
||||
import org.jetbrains.plugins.github.pullrequest.data.provider.GHPRDataProvider
|
||||
import org.jetbrains.plugins.github.pullrequest.data.provider.threadsComputationFlow
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewCommentLocation
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewCommentPosition
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRThreadsViewModels
|
||||
|
||||
@@ -29,7 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewCommentLocation
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.editor.GHPRAICommentEditorInlayRenderer
|
||||
|
||||
@@ -2,20 +2,16 @@
|
||||
package org.jetbrains.plugins.github.pullrequest.ui.editor
|
||||
|
||||
import com.intellij.CommonBundle
|
||||
import com.intellij.collaboration.async.collectScoped
|
||||
import com.intellij.collaboration.async.inverted
|
||||
import com.intellij.collaboration.async.launchNow
|
||||
import com.intellij.collaboration.async.mapScoped
|
||||
import com.intellij.collaboration.async.mapState
|
||||
import com.intellij.collaboration.async.stateInNow
|
||||
import com.intellij.collaboration.messages.CollaborationToolsBundle
|
||||
import com.intellij.collaboration.ui.ComponentListPanelFactory
|
||||
import com.intellij.collaboration.ui.HorizontalListPanel
|
||||
import com.intellij.collaboration.ui.SimpleHtmlPane
|
||||
import com.intellij.collaboration.ui.VerticalListPanel
|
||||
import com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil
|
||||
import com.intellij.collaboration.ui.codereview.CodeReviewTimelineUIUtil
|
||||
import com.intellij.collaboration.ui.codereview.comment.CodeReviewAIUIUtil
|
||||
import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentTextFieldFactory
|
||||
import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentUIUtil
|
||||
import com.intellij.collaboration.ui.codereview.comment.CommentInputActionsComponentFactory
|
||||
@@ -24,23 +20,19 @@ import com.intellij.collaboration.ui.codereview.timeline.comment.CommentTextFiel
|
||||
import com.intellij.collaboration.ui.codereview.timeline.thread.CodeReviewResolvableItemViewModel
|
||||
import com.intellij.collaboration.ui.codereview.timeline.thread.TimelineThreadCommentsPanel
|
||||
import com.intellij.collaboration.ui.util.*
|
||||
import com.intellij.ml.llm.MLLlmIcons
|
||||
import com.intellij.openapi.ui.MessageDialogBuilder
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.util.IconUtil
|
||||
import com.intellij.util.ui.JBUI
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.jetbrains.plugins.github.i18n.GithubBundle
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel.CommentItem
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewThreadCommentComponentFactory
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewThreadComponentFactory
|
||||
import javax.swing.AbstractAction
|
||||
import javax.swing.Action
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
|
||||
internal object GHPRReviewEditorComponentsFactory {
|
||||
@@ -174,99 +166,4 @@ internal object GHPRReviewEditorComponentsFactory {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createAICommentIn(cs: CoroutineScope, userIcon: Icon, vm: GHPRAICommentViewModel): JComponent {
|
||||
val textPane = SimpleHtmlPane().apply {
|
||||
bindTextHtmlIn(cs, vm.textHtml)
|
||||
}
|
||||
val actions = HorizontalListPanel(CodeReviewCommentUIUtil.Actions.HORIZONTAL_GAP).apply {
|
||||
add(CodeReviewCommentUIUtil.createDeleteCommentIconButton {
|
||||
vm.reject()
|
||||
}.apply {
|
||||
bindDisabledIn(cs, vm.isBusy)
|
||||
})
|
||||
}
|
||||
val title = CodeReviewTimelineUIUtil.createTitleTextPane("AI Assistant", null, null)
|
||||
val commentPanel = CodeReviewChatItemUIUtil.build(CodeReviewChatItemUIUtil.ComponentType.COMPACT,
|
||||
{ IconUtil.resizeSquared(MLLlmIcons.AiAssistantColored, it) },
|
||||
textPane) {
|
||||
iconTooltip = "AI Assistant"
|
||||
maxContentWidth = null
|
||||
withHeader(title, actions)
|
||||
}
|
||||
|
||||
val doneAction = swingAction("Done") {
|
||||
vm.accept()
|
||||
}
|
||||
val buttonsPanel = HorizontalListPanel(10).apply {
|
||||
border = JBUI.Borders.empty(8, CodeReviewChatItemUIUtil.ComponentType.COMPACT.fullLeftShift)
|
||||
ActionLink("Chat") {
|
||||
vm.startChat()
|
||||
}.apply {
|
||||
isFocusPainted = false
|
||||
bindDisabledIn(cs, vm.isBusy)
|
||||
autoHideOnDisable = false
|
||||
}.also(::add)
|
||||
ActionLink(doneAction).apply {
|
||||
isFocusPainted = false
|
||||
bindDisabledIn(cs, vm.isBusy)
|
||||
autoHideOnDisable = false
|
||||
}.also(::add)
|
||||
}
|
||||
cs.launchNow {
|
||||
vm.chatVm.collect {
|
||||
buttonsPanel.isVisible = it == null
|
||||
}
|
||||
}
|
||||
|
||||
val chatPanel = VerticalListPanel(gap = 5).apply {
|
||||
border = JBUI.Borders.empty(10)
|
||||
}
|
||||
cs.launchNow {
|
||||
vm.chatVm.collectScoped { chatVm ->
|
||||
chatPanel.isVisible = chatVm != null
|
||||
|
||||
val messagesPanel = VerticalListPanel(10)
|
||||
if (chatVm != null) {
|
||||
launchNow {
|
||||
chatVm.messages.collect { comment ->
|
||||
val commentPanel = CodeReviewChatItemUIUtil.build(CodeReviewChatItemUIUtil.ComponentType.SUPER_COMPACT,
|
||||
{
|
||||
if (comment.isResponse) IconUtil.resizeSquared(MLLlmIcons.AiAssistantColored, it)
|
||||
else userIcon
|
||||
},
|
||||
SimpleHtmlPane(comment.message)) {
|
||||
maxContentWidth = null
|
||||
}
|
||||
messagesPanel.add(commentPanel)
|
||||
}
|
||||
}
|
||||
chatPanel.add(messagesPanel)
|
||||
val submitShortcutText = CommentInputActionsComponentFactory.submitShortcutText
|
||||
|
||||
val summarizeAction = swingAction("Summarize") {
|
||||
chatVm.summarize()
|
||||
}
|
||||
|
||||
val input = CodeReviewCommentTextFieldFactory.createIn(cs, chatVm.newMessageVm, CommentInputActionsComponentFactory.Config(
|
||||
primaryAction = MutableStateFlow(chatVm.newMessageVm.submitActionIn(cs, "Submit") {
|
||||
chatVm.newMessageVm.submit()
|
||||
}),
|
||||
additionalActions = MutableStateFlow(listOf(doneAction, summarizeAction)),
|
||||
submitHint = MutableStateFlow("$submitShortcutText to submit")
|
||||
))
|
||||
|
||||
chatPanel.add(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val panel = VerticalListPanel().apply {
|
||||
border = JBUI.Borders.empty(CodeReviewCommentUIUtil.getInlayPadding(CodeReviewChatItemUIUtil.ComponentType.COMPACT))
|
||||
add(commentPanel)
|
||||
add(chatPanel)
|
||||
add(buttonsPanel)
|
||||
}
|
||||
return CodeReviewCommentUIUtil.createEditorInlayPanel(panel, CodeReviewAIUIUtil.AI_COLOR)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ package org.jetbrains.plugins.github.pullrequest.ui.editor
|
||||
import com.intellij.collaboration.ui.codereview.editor.CodeReviewInlayModel
|
||||
import com.intellij.collaboration.util.Hideable
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
|
||||
|
||||
sealed interface GHPREditorMappedComponentModel : CodeReviewInlayModel {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package org.jetbrains.plugins.github.pullrequest.ui.editor
|
||||
|
||||
import com.intellij.collaboration.async.extensionListFlow
|
||||
import com.intellij.collaboration.ui.codereview.editor.CodeReviewComponentInlayRenderer
|
||||
import com.intellij.collaboration.ui.util.bindContentIn
|
||||
import com.intellij.openapi.extensions.ExtensionPointName
|
||||
import com.intellij.ui.components.panels.Wrapper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
|
||||
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
|
||||
@ApiStatus.Internal
|
||||
class GHPRReviewThreadEditorInlayRenderer internal constructor(cs: CoroutineScope, vm: GHPRCompactReviewThreadViewModel)
|
||||
@@ -20,7 +25,18 @@ class GHPRNewCommentEditorInlayRenderer internal constructor(cs: CoroutineScope,
|
||||
GHPRReviewEditorComponentsFactory.createNewCommentIn(cs, vm)
|
||||
)
|
||||
|
||||
interface GHPRAICommentComponentFactory {
|
||||
companion object {
|
||||
val EP_NAME = ExtensionPointName<GHPRAICommentComponentFactory>("intellij.vcs.github.commentComponentFactory")
|
||||
}
|
||||
|
||||
fun createAICommentIn(cs: CoroutineScope, userIcon: Icon, vm: GHPRAICommentViewModel): JComponent
|
||||
}
|
||||
|
||||
internal class GHPRAICommentEditorInlayRenderer internal constructor(cs: CoroutineScope, userIcon: Icon, vm: GHPRAICommentViewModel)
|
||||
: CodeReviewComponentInlayRenderer(
|
||||
GHPRReviewEditorComponentsFactory.createAICommentIn(cs, userIcon, vm)
|
||||
)
|
||||
: CodeReviewComponentInlayRenderer(Wrapper().apply {
|
||||
bindContentIn(cs, GHPRAICommentComponentFactory.EP_NAME.extensionListFlow()) { extensions ->
|
||||
val extension = extensions.firstOrNull() ?: return@bindContentIn null
|
||||
extension.createAICommentIn(cs, userIcon, vm)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user