[gitlab] Add a button to MR creation to generate a title

GitOrigin-RevId: 53a9cb6bc2e0827d80d434d47482a2584d4ccb4c
This commit is contained in:
Chris Lemaire
2024-08-30 16:20:22 +02:00
committed by intellij-monorepo-bot
parent 34dc69e116
commit aa2c2605c5
5 changed files with 227 additions and 27 deletions

View File

@@ -3,20 +3,29 @@ package com.intellij.collaboration.ui.codereview.create
import com.intellij.collaboration.ui.CollaborationToolsUIUtil
import com.intellij.collaboration.ui.codereview.comment.CodeReviewMarkdownEditor
import com.intellij.openapi.actionSystem.ActionToolbar
import com.intellij.openapi.actionSystem.impl.ActionToolbarImpl
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.text.StringUtil
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLayeredPane
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.vcs.log.VcsCommitMetadata
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
import java.awt.Component
import java.awt.Dimension
import java.awt.Insets
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ListCellRenderer
import javax.swing.SpringLayout
import javax.swing.border.AbstractBorder
import javax.swing.text.AttributeSet
import javax.swing.text.PlainDocument
@@ -67,6 +76,53 @@ object CodeReviewCreateReviewUIUtil {
}
fun createCommitListCellRenderer(): ListCellRenderer<VcsCommitMetadata> = CodeReviewTwoLinesCommitRenderer()
@ApiStatus.Internal
fun createGenerationToolbarOverlay(editorPanel: JComponent, toolbar: ActionToolbar, getSpacerWidth: (() -> Int)? = null): JComponent {
val buttonPanel = toolbar.component.apply {
border = JBUI.Borders.empty()
isOpaque = false
putClientProperty(ActionToolbarImpl.IMPORTANT_TOOLBAR_KEY, true)
}
return JBLayeredPane().apply {
val layout = SpringLayout()
this.layout = layout
layout.putConstraint(SpringLayout.NORTH, editorPanel, 0, SpringLayout.NORTH, this)
layout.putConstraint(SpringLayout.SOUTH, editorPanel, 0, SpringLayout.SOUTH, this)
layout.putConstraint(SpringLayout.EAST, editorPanel, 0, SpringLayout.EAST, this)
layout.putConstraint(SpringLayout.WEST, editorPanel, 0, SpringLayout.WEST, this)
add(editorPanel, 1 as Any)
layout.putConstraint(SpringLayout.SOUTH, buttonPanel, 0, SpringLayout.SOUTH, this)
if (getSpacerWidth != null) {
// Needed to avoid overlapping with the editor's vertical scrollbar
val spacer = JPanel().apply {
isOpaque = false
isEnabled = false
border = object : AbstractBorder() {
override fun getBorderInsets(c: Component?, insets: Insets): Insets {
super.getBorderInsets(c, insets)
insets.right = getSpacerWidth()
return insets
}
}
}
layout.putConstraint(SpringLayout.SOUTH, spacer, 0, SpringLayout.SOUTH, this)
layout.putConstraint(SpringLayout.EAST, spacer, 0, SpringLayout.EAST, this)
add(spacer, 0 as Any)
layout.putConstraint(SpringLayout.EAST, buttonPanel, 0, SpringLayout.WEST, spacer)
}
else {
layout.putConstraint(SpringLayout.EAST, buttonPanel, 0, SpringLayout.EAST, this)
}
add(buttonPanel, 2 as Any)
}
}
}
@ApiStatus.Internal

View File

@@ -43,6 +43,12 @@
<module name="intellij.platform.collaborationTools"/>
</dependencies>
<extensionPoints>
<extensionPoint qualifiedName="intellij.vcs.gitlab.titleGenerator"
interface="org.jetbrains.plugins.gitlab.mergerequest.ui.create.model.GitLabTitleGeneratorExtension"
dynamic="true"/>
</extensionPoints>
<extensions defaultExtensionNs="com.intellij">
<applicationService serviceImplementation="org.jetbrains.plugins.gitlab.authentication.accounts.GitLabPersistentAccounts"/>
<applicationService serviceInterface="org.jetbrains.plugins.gitlab.authentication.accounts.GitLabAccountManager"
@@ -243,5 +249,7 @@
</action>
<add-to-group group-id="Git.Experimental.Branch.Popup.Actions"/>
</group>
<group id="GitLab.Merge.Request.Create.Title.Actions"/>
</actions>
</idea-plugin>

View File

@@ -1,6 +1,7 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.gitlab.mergerequest.ui.create
import com.intellij.collaboration.async.extensionListFlow
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.ui.CollaborationToolsUIUtil
import com.intellij.collaboration.ui.SingleValueModel
@@ -8,16 +9,20 @@ import com.intellij.collaboration.ui.bindValueIn
import com.intellij.collaboration.ui.codereview.commits.CommitsBrowserComponentBuilder
import com.intellij.collaboration.ui.codereview.create.CodeReviewCreateReviewLayoutBuilder
import com.intellij.collaboration.ui.codereview.create.CodeReviewCreateReviewUIUtil
import com.intellij.collaboration.ui.util.bindContentIn
import com.intellij.collaboration.ui.util.bindTextIn
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.project.Project
import com.intellij.ui.components.panels.Wrapper
import com.intellij.vcs.log.VcsCommitMetadata
import git4idea.ui.branch.MergeDirectionComponentFactory
import git4idea.ui.branch.MergeDirectionModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import org.jetbrains.plugins.gitlab.mergerequest.ui.create.model.BranchState
import org.jetbrains.plugins.gitlab.mergerequest.ui.create.model.GitLabMergeRequestCreateDirectionModel
import org.jetbrains.plugins.gitlab.mergerequest.ui.create.model.GitLabMergeRequestCreateViewModel
import org.jetbrains.plugins.gitlab.mergerequest.ui.create.model.*
import org.jetbrains.plugins.gitlab.util.GitLabBundle
import org.jetbrains.plugins.gitlab.util.GitLabProjectMapping
import org.jetbrains.plugins.gitlab.util.GitLabStatistics
@@ -45,14 +50,7 @@ internal object GitLabMergeRequestCreateComponentFactory {
val directionSelector = createDirectionSelector(directionModel)
val commitsLoadingPanel = createCommitsPanel(project, cs, createVm)
val titleField = CodeReviewCreateReviewUIUtil.createTitleEditor(GitLabBundle.message("merge.request.create.title.placeholder")).apply {
document.addDocumentListener(object : DocumentListener {
fun updateText() = createVm.updateTitle(text)
override fun insertUpdate(e: DocumentEvent?) = updateText()
override fun removeUpdate(e: DocumentEvent?) = updateText()
override fun changedUpdate(e: DocumentEvent?) = updateText()
})
}
val titlePanel = cs.createTitleEditorPanel(createVm)
val reviewersPanel = GitLabMergeRequestCreateReviewersComponentFactory.create(cs, createVm)
val statusPanel = GitLabMergeRequestCreateStatusComponentFactory.create(cs, createVm)
val actionsPanel = GitLabMergeRequestCreateActionsComponentFactory.create(project, cs, createVm)
@@ -61,15 +59,70 @@ internal object GitLabMergeRequestCreateComponentFactory {
.addComponent(directionSelector, zeroMinWidth = true)
.addComponent(commitsLoadingPanel, stretchYWithWeight = 0.5f, withoutBorder = true)
.addSeparator()
.addComponent(titleField, zeroMinWidth = true)
.addComponent(titlePanel, stretchYWithWeight = 0.3f, zeroMinWidth = true)
.addSeparator()
.addComponent(reviewersPanel, zeroMinWidth = true, stretchYWithWeight = 0.3f)
.addComponent(reviewersPanel, zeroMinWidth = true, stretchYWithWeight = 0.2f)
.addSeparator()
.addComponent(statusPanel)
.addComponent(actionsPanel, withListBackground = false)
.build()
}
private fun CoroutineScope.createTitleEditorPanel(createVm: GitLabMergeRequestCreateViewModel): JComponent {
val cs = this
val editor = CodeReviewCreateReviewUIUtil.createTitleEditor(GitLabBundle.message("merge.request.create.title.placeholder")).apply {
document.addDocumentListener(object : DocumentListener {
fun updateText() = createVm.updateTitle(text)
override fun insertUpdate(e: DocumentEvent?) = updateText()
override fun removeUpdate(e: DocumentEvent?) = updateText()
override fun changedUpdate(e: DocumentEvent?) = updateText()
})
bindTextIn(cs, createVm.title)
}
return Wrapper().apply {
bindContentIn(cs, GitLabTitleGeneratorExtension.EP_NAME.extensionListFlow()) { extensions ->
if (extensions.isEmpty()) {
editor
}
else {
wrapTitleEditorWithGenerateActions(createVm, editor)
}
}
}
}
private fun CoroutineScope.wrapTitleEditorWithGenerateActions(
createVm: GitLabMergeRequestCreateViewModel,
editor: JComponent,
): JComponent {
val wrappedEditor = UiDataProvider.wrapComponent(editor) {
it[GitLabMergeRequestCreateTitleGenerationViewModel.DATA_KEY] = createVm.titleGenerationVm.value
}
val actionManager = ActionManager.getInstance()
val actionGroup = actionManager.getAction("GitLab.Merge.Request.Create.Title.Actions") as ActionGroup
val toolbar = actionManager.createActionToolbar("MrCreationTitle", actionGroup, true).apply {
targetComponent = wrappedEditor
}
// Force an action's update with new values for commits and generating state
launchNow {
createVm.titleGenerationVm.collect {
if (it == null) {
toolbar.updateActionsAsync()
return@collect
}
it.isGenerating.collect {
toolbar.updateActionsAsync()
}
}
}
return CodeReviewCreateReviewUIUtil.createGenerationToolbarOverlay(wrappedEditor, toolbar)
}
private fun createDirectionSelector(directionModel: MergeDirectionModel<GitLabProjectMapping>): JComponent {
return MergeDirectionComponentFactory(
directionModel,

View File

@@ -0,0 +1,68 @@
// 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.gitlab.mergerequest.ui.create.model
import com.intellij.collaboration.util.SingleCoroutineLauncher
import com.intellij.openapi.actionSystem.DataKey
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.project.Project
import com.intellij.platform.util.coroutines.childScope
import com.intellij.vcs.log.VcsCommitMetadata
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.gitlab.mergerequest.ui.create.model.GitLabTitleGeneratorExtension.GenerationError
import org.jetbrains.plugins.gitlab.mergerequest.ui.create.model.GitLabTitleGeneratorExtension.GenerationStep
@ApiStatus.Internal
interface GitLabTitleGeneratorExtension {
companion object {
val EP_NAME = ExtensionPointName<GitLabTitleGeneratorExtension>("intellij.vcs.gitlab.titleGenerator")
}
sealed interface GenerationState
data class GenerationError(val e: Exception) : GenerationState
data class GenerationStep(val title: String) : GenerationState
fun generate(project: Project, commits: List<VcsCommitMetadata>): Flow<GenerationState>
}
@ApiStatus.Internal
interface GitLabMergeRequestCreateTitleGenerationViewModel {
companion object {
val DATA_KEY = DataKey.create<GitLabMergeRequestCreateTitleGenerationViewModel>("GitLabCreateTitleGenerationViewModel")
}
val isGenerating: StateFlow<Boolean>
fun stopGeneration()
fun startGeneration()
}
internal class GitLabMergeRequestCreateTitleGenerationViewModelImpl(
parentCs: CoroutineScope,
private val project: Project,
private val extension: GitLabTitleGeneratorExtension,
private val commits: List<VcsCommitMetadata>,
private val setTitle: (String) -> Unit,
) : GitLabMergeRequestCreateTitleGenerationViewModel {
private val taskLauncher = SingleCoroutineLauncher(parentCs.childScope("Generate Title"))
override val isGenerating: StateFlow<Boolean> = taskLauncher.busy
override fun stopGeneration() {
taskLauncher.cancel()
}
override fun startGeneration() {
taskLauncher.launch {
extension.generate(project, commits).collect {
when (it) {
is GenerationError -> throw it.e
is GenerationStep -> {
setTitle(it.title)
}
}
}
}
}
}

View File

@@ -2,9 +2,7 @@
package org.jetbrains.plugins.gitlab.mergerequest.ui.create.model
import com.intellij.collaboration.api.HttpStatusErrorException
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.async.modelFlow
import com.intellij.collaboration.async.withInitial
import com.intellij.collaboration.async.*
import com.intellij.collaboration.ui.ListenableProgressIndicator
import com.intellij.collaboration.ui.icon.IconsProvider
import com.intellij.collaboration.util.ResultUtil.runCatchingUser
@@ -43,6 +41,9 @@ internal interface GitLabMergeRequestCreateViewModel {
val projectData: GitLabProject
val avatarIconProvider: IconsProvider<GitLabUserDTO>
val title: StateFlow<String>
val titleGenerationVm: StateFlow<GitLabMergeRequestCreateTitleGenerationViewModel?>
val isBusy: Flow<Boolean>
val allowsMultipleReviewers: Flow<Boolean>
@@ -50,7 +51,7 @@ internal interface GitLabMergeRequestCreateViewModel {
val existingMergeRequest: Flow<String?>
val creatingProgressText: Flow<String?>
val commits: SharedFlow<Result<List<VcsCommitMetadata>>?>
val commits: StateFlow<Result<List<VcsCommitMetadata>>?>
val reviewRequirementsErrorState: Flow<MergeRequestRequirementsErrorType?>
val reviewCreatingError: Flow<Throwable?>
@@ -76,7 +77,7 @@ internal class GitLabMergeRequestCreateViewModelImpl(
override val projectData: GitLabProject,
override val avatarIconProvider: IconsProvider<GitLabUserDTO>,
override val openReviewTabAction: suspend (mrIid: String) -> Unit,
private val onReviewCreated: () -> Unit
private val onReviewCreated: () -> Unit,
) : GitLabMergeRequestCreateViewModel {
private val cs: CoroutineScope = parentCs.childScope()
private val taskLauncher = SingleCoroutineLauncher(cs)
@@ -128,8 +129,8 @@ internal class GitLabMergeRequestCreateViewModelImpl(
}
.distinctUntilChangedBy { it }
override val commits: SharedFlow<Result<List<VcsCommitMetadata>>?> = commitRevisionComparisonFlow.transformLatest { revisionComparison ->
if (revisionComparison == null || revisionComparison.baseRevision == null || revisionComparison.headRevision == null) {
override val commits: StateFlow<Result<List<VcsCommitMetadata>>?> = commitRevisionComparisonFlow.transformLatest { revisionComparison ->
if (revisionComparison?.baseRevision == null || revisionComparison.headRevision == null) {
emit(Result.success(emptyList()))
return@transformLatest
}
@@ -144,6 +145,7 @@ internal class GitLabMergeRequestCreateViewModelImpl(
}
emit(result)
}.modelFlow(cs, thisLogger())
.stateInNow(cs, null)
override val reviewRequirementsErrorState: Flow<MergeRequestRequirementsErrorType?> =
combine(branchState, commits) { branchState, commits ->
@@ -164,7 +166,19 @@ internal class GitLabMergeRequestCreateViewModelImpl(
private val _adjustedReviewers: MutableStateFlow<List<GitLabUserDTO>> = MutableStateFlow(listOf())
override val adjustedReviewers: StateFlow<List<GitLabUserDTO>> = _adjustedReviewers.asStateFlow()
private val title: MutableStateFlow<String> = MutableStateFlow("")
private val _title: MutableStateFlow<String> = MutableStateFlow("")
override val title: StateFlow<String> = _title.asStateFlow()
override val titleGenerationVm: StateFlow<GitLabMergeRequestCreateTitleGenerationViewModel?> =
commits.combine(GitLabTitleGeneratorExtension.EP_NAME.extensionListFlow()) { commits, extensions ->
val commits = commits?.getOrNull() ?: return@combine null
if (commits.isEmpty()) return@combine null
val extension = extensions.firstOrNull() ?: return@combine null
commits to extension
}.mapNullableScoped { (commits, extension) ->
GitLabMergeRequestCreateTitleGenerationViewModelImpl(this, project, extension, commits, ::updateTitle)
}.stateIn(cs, SharingStarted.Lazily, null)
init {
cs.launch {
@@ -182,18 +196,19 @@ internal class GitLabMergeRequestCreateViewModelImpl(
val commits = commitsResult?.getOrNull()
if (commits != null && commits.size == 1 && title.value.isEmpty()) {
updateTitle(commits.first().subject.lines().firstOrNull() ?: return@combine)
} else if (title.value.isEmpty()) {
}
else if (title.value.isEmpty()) {
updateTitle(when (val branch = branchState?.headBranch ?: return@combine) {
is GitRemoteBranch -> branch.nameForRemoteOperations
else -> branch.name
})
is GitRemoteBranch -> branch.nameForRemoteOperations
else -> branch.name
})
}
}
}
}
override fun updateTitle(text: String) {
title.value = text
_title.value = text
}
override fun updateBranchState(state: BranchState?) {
@@ -269,7 +284,7 @@ internal data class BranchState(
val baseRepo: GitLabProjectMapping,
val baseBranch: GitRemoteBranch,
val headRepo: GitLabProjectMapping,
val headBranch: GitBranch
val headBranch: GitBranch,
) {
companion object {
internal fun fromDirectionModel(directionModel: GitLabMergeRequestCreateDirectionModel): BranchState? = with(directionModel) {