From aa2c2605c55bfa557991f474aaf88a8e2dd6a4cd Mon Sep 17 00:00:00 2001 From: Chris Lemaire Date: Fri, 30 Aug 2024 16:20:22 +0200 Subject: [PATCH] [gitlab] Add a button to MR creation to generate a title GitOrigin-RevId: 53a9cb6bc2e0827d80d434d47482a2584d4ccb4c --- .../create/CodeReviewCreateReviewUIUtil.kt | 56 +++++++++++++ plugins/gitlab/resources/META-INF/plugin.xml | 8 ++ ...itLabMergeRequestCreateComponentFactory.kt | 79 ++++++++++++++++--- ...geRequestCreateTitleGenerationViewModel.kt | 68 ++++++++++++++++ .../GitLabMergeRequestCreateViewModel.kt | 43 ++++++---- 5 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateTitleGenerationViewModel.kt diff --git a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/create/CodeReviewCreateReviewUIUtil.kt b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/create/CodeReviewCreateReviewUIUtil.kt index 9428ef5e7956..dca64ab772df 100644 --- a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/create/CodeReviewCreateReviewUIUtil.kt +++ b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/create/CodeReviewCreateReviewUIUtil.kt @@ -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 = 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 diff --git a/plugins/gitlab/resources/META-INF/plugin.xml b/plugins/gitlab/resources/META-INF/plugin.xml index e20a932bc9af..fc15bf263150 100644 --- a/plugins/gitlab/resources/META-INF/plugin.xml +++ b/plugins/gitlab/resources/META-INF/plugin.xml @@ -43,6 +43,12 @@ + + + + + + diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateComponentFactory.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateComponentFactory.kt index b56b66e9e80b..a2bc36950b86 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateComponentFactory.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateComponentFactory.kt @@ -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): JComponent { return MergeDirectionComponentFactory( directionModel, diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateTitleGenerationViewModel.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateTitleGenerationViewModel.kt new file mode 100644 index 000000000000..b54af96a39a3 --- /dev/null +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateTitleGenerationViewModel.kt @@ -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("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): Flow +} + +@ApiStatus.Internal +interface GitLabMergeRequestCreateTitleGenerationViewModel { + companion object { + val DATA_KEY = DataKey.create("GitLabCreateTitleGenerationViewModel") + } + + val isGenerating: StateFlow + + fun stopGeneration() + fun startGeneration() +} + +internal class GitLabMergeRequestCreateTitleGenerationViewModelImpl( + parentCs: CoroutineScope, + private val project: Project, + private val extension: GitLabTitleGeneratorExtension, + private val commits: List, + private val setTitle: (String) -> Unit, +) : GitLabMergeRequestCreateTitleGenerationViewModel { + private val taskLauncher = SingleCoroutineLauncher(parentCs.childScope("Generate Title")) + override val isGenerating: StateFlow = 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) + } + } + } + } + } +} \ No newline at end of file diff --git a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateViewModel.kt b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateViewModel.kt index 12dacc8775ee..d3c0db659f8b 100644 --- a/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateViewModel.kt +++ b/plugins/gitlab/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateViewModel.kt @@ -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 + val title: StateFlow + val titleGenerationVm: StateFlow + val isBusy: Flow val allowsMultipleReviewers: Flow @@ -50,7 +51,7 @@ internal interface GitLabMergeRequestCreateViewModel { val existingMergeRequest: Flow val creatingProgressText: Flow - val commits: SharedFlow>?> + val commits: StateFlow>?> val reviewRequirementsErrorState: Flow val reviewCreatingError: Flow @@ -76,7 +77,7 @@ internal class GitLabMergeRequestCreateViewModelImpl( override val projectData: GitLabProject, override val avatarIconProvider: IconsProvider, 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>?> = commitRevisionComparisonFlow.transformLatest { revisionComparison -> - if (revisionComparison == null || revisionComparison.baseRevision == null || revisionComparison.headRevision == null) { + override val commits: StateFlow>?> = 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 = combine(branchState, commits) { branchState, commits -> @@ -164,7 +166,19 @@ internal class GitLabMergeRequestCreateViewModelImpl( private val _adjustedReviewers: MutableStateFlow> = MutableStateFlow(listOf()) override val adjustedReviewers: StateFlow> = _adjustedReviewers.asStateFlow() - private val title: MutableStateFlow = MutableStateFlow("") + private val _title: MutableStateFlow = MutableStateFlow("") + override val title: StateFlow = _title.asStateFlow() + override val titleGenerationVm: StateFlow = + 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) {