refactor [gitlab]: a better reviewers selector when creating a merge request

Now loads items in batches and displays a correct progress indicator. Also the UI is now aligned with GitHub

GitOrigin-RevId: 14e22d7db4716d44acfdde00da70eca238be33de
This commit is contained in:
Ivan Semenov
2026-02-13 14:11:18 +00:00
committed by intellij-monorepo-bot
parent 261367fd29
commit 6b8f1270fe
10 changed files with 521 additions and 139 deletions
@@ -310,6 +310,69 @@ object ChooserPopupUtil {
// Multiple options:
/**
* Displays a chooser popup that allows selecting multiple items from a list, with support for incremental loading.
*
* @param point The location where the popup will be shown as a relative point.
* @param currentItems The list of currently available items that should be initially displayed in the chooser.
* @param listState A [StateFlow] representing the state of incrementally loaded values for the items in the popup.
* It provides new items, tracks loading status, and contains any exceptions encountered.
* @param presenter A function that maps each item in the list to a [PopupItemPresentation], which determines how the
* item will appear in the popup (e.g., text, icon).
* @param popupConfig A [PopupConfig] to configure the popup's behavior and appearance, with a default
* configuration provided if not specified.
* @return A list of items selected by the user from the popup.
*/
@ApiStatus.Internal
@JvmOverloads
suspend fun <T : Any> showMultipleChooserPopupWithIncrementalLoading(
point: RelativePoint,
currentItems: List<T>,
listState: StateFlow<IncrementallyComputedValue<List<T>>>,
presenter: (T) -> PopupItemPresentation,
popupConfig: PopupConfig = PopupConfig.DEFAULT,
): List<T> {
val listModel = MultiChooserListModel<T>().apply {
add(currentItems)
setChosen(currentItems)
}
val list = createSelectableList(listModel, presenter)
list.launchOnShow("List items loader") {
listState.collect { state ->
list.setPaintBusy(state.isLoading)
state.exceptionOrNull?.let { exception ->
list.emptyText.showError(exception, popupConfig.errorPresenter) // TODO: show error even when list is not empty
} ?: run {
list.emptyText.clear()
}
state.onNoValue {
listModel.removeAllExceptChosen()
}.onValueAvailable { newList ->
val selected = list.selectedValue
listModel.retainChosenAndUpdate(newList)
list.setSelectedValue(selected, true)
}
}
}
@Suppress("UNCHECKED_CAST")
val popup = PopupChooserBuilder(list)
.setFilteringEnabled {
filterByNamesFromPresentation(presenter)(it as T)
}
.setCloseOnEnter(false)
.configure(popupConfig)
.createPopup()
CollaborationToolsPopupUtil.configureSearchField(popup, popupConfig)
PopupUtil.setPopupToggleComponent(popup, point.component)
return popup.showAndAwaitSubmissions(listModel, point, popupConfig.showDirection)
}
/**
* Displays an asynchronous popup allowing users to select multiple items from a dynamically loaded list.
*
@@ -57,4 +57,67 @@ internal class MultiChooserListModel<T> : AbstractListModel<T>() {
fireIntervalAdded(this, 0, lastIndex + count)
}
}
/**
* Updates the list of items, ensuring that chosen items stay in the list
*/
fun retainChosenAndUpdate(newList: List<T>) {
val oldSize = items.size
items.clear()
itemsSet.clear()
chosenItems.forEach { chosen ->
items.add(chosen)
itemsSet.add(chosen)
}
newList.forEach { item ->
if (itemsSet.add(item)) {
items.add(item)
}
}
val newSize = items.size
when {
oldSize == 0 && newSize > 0 -> {
fireIntervalAdded(this, 0, newSize - 1)
}
newSize == 0 && oldSize > 0 -> {
fireIntervalRemoved(this, 0, oldSize - 1)
}
oldSize != newSize -> {
val minSize = minOf(oldSize, newSize)
if (minSize > 0) {
fireContentsChanged(this, 0, minSize - 1)
}
if (newSize > oldSize) {
fireIntervalAdded(this, oldSize, newSize - 1)
}
else {
fireIntervalRemoved(this, newSize, oldSize - 1)
}
}
else -> {
fireContentsChanged(this, 0, newSize - 1)
}
}
}
fun removeAllExceptChosen() {
val oldSize = items.size
items.clear()
itemsSet.clear()
if (oldSize != 0) {
fireIntervalRemoved(this, 0, oldSize - 1)
}
chosenItems.forEach { chosen ->
items.add(chosen)
itemsSet.add(chosen)
}
if (items.isNotEmpty()) {
fireIntervalAdded(this, 0, items.lastIndex)
}
}
}
@@ -217,4 +217,217 @@ internal class MultiChooserListModelTest {
model.add(listOf("item1", "item2"))
assertThat(eventFired).isFalse()
}
@Test
fun `test removeAllExceptChosen removes non-chosen items`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3", "item4"))
model.setChosen(listOf("item1", "item3"))
model.removeAllExceptChosen()
assertThat(model.size).isEqualTo(2)
assertThat(model.getElementAt(0)).isEqualTo("item1")
assertThat(model.getElementAt(1)).isEqualTo("item3")
assertThat(model.getChosenItems()).containsExactly("item1", "item3")
}
@Test
fun `test removeAllExceptChosen with no chosen items clears list`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3"))
model.removeAllExceptChosen()
assertThat(model.size).isEqualTo(0)
assertThat(model.getChosenItems()).isEmpty()
}
@Test
fun `test removeAllExceptChosen with all chosen items keeps all`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3"))
model.setChosen(listOf("item1", "item2", "item3"))
model.removeAllExceptChosen()
assertThat(model.size).isEqualTo(3)
assertThat(model.getElementAt(0)).isEqualTo("item1")
assertThat(model.getElementAt(1)).isEqualTo("item2")
assertThat(model.getElementAt(2)).isEqualTo("item3")
}
@Test
fun `test removeAllExceptChosen fires interval removed event`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3"))
model.setChosen(listOf("item1"))
var intervalRemovedFired = false
model.addListDataListener(object : ListDataListener {
override fun intervalAdded(e: ListDataEvent) {}
override fun intervalRemoved(e: ListDataEvent) {
intervalRemovedFired = true
}
override fun contentsChanged(e: ListDataEvent) {}
})
model.removeAllExceptChosen()
assertThat(intervalRemovedFired).isTrue()
}
@Test
fun `test removeAllExceptChosen on empty list does nothing`() {
val model = MultiChooserListModel<String>()
var eventFired = false
model.addListDataListener(object : ListDataListener {
override fun intervalAdded(e: ListDataEvent) {
eventFired = true
}
override fun intervalRemoved(e: ListDataEvent) {
eventFired = true
}
override fun contentsChanged(e: ListDataEvent) {
eventFired = true
}
})
model.removeAllExceptChosen()
assertThat(eventFired).isFalse()
assertThat(model.size).isEqualTo(0)
}
@Test
fun `test retainChosenAndUpdate replaces items with new list`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3"))
model.retainChosenAndUpdate(listOf("item4", "item5"))
assertThat(model.size).isEqualTo(2)
assertThat(model.getElementAt(0)).isEqualTo("item4")
assertThat(model.getElementAt(1)).isEqualTo("item5")
}
@Test
fun `test retainChosenAndUpdate keeps chosen items`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3"))
model.setChosen(listOf("item1", "item3"))
model.retainChosenAndUpdate(listOf("item4", "item5"))
assertThat(model.size).isEqualTo(4)
assertThat(model.getElementAt(0)).isEqualTo("item1")
assertThat(model.getElementAt(1)).isEqualTo("item3")
assertThat(model.getElementAt(2)).isEqualTo("item4")
assertThat(model.getElementAt(3)).isEqualTo("item5")
assertThat(model.isChosen("item1")).isTrue()
assertThat(model.isChosen("item3")).isTrue()
}
@Test
fun `test retainChosenAndUpdate avoids duplicates between chosen and new items`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3"))
model.setChosen(listOf("item1", "item2"))
model.retainChosenAndUpdate(listOf("item2", "item3", "item4"))
assertThat(model.size).isEqualTo(4)
assertThat(model.getElementAt(0)).isEqualTo("item1")
assertThat(model.getElementAt(1)).isEqualTo("item2")
assertThat(model.getElementAt(2)).isEqualTo("item3")
assertThat(model.getElementAt(3)).isEqualTo("item4")
}
@Test
fun `test retainChosenAndUpdate with empty new list keeps chosen items`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3"))
model.setChosen(listOf("item1", "item3"))
model.retainChosenAndUpdate(emptyList())
assertThat(model.size).isEqualTo(2)
assertThat(model.getElementAt(0)).isEqualTo("item1")
assertThat(model.getElementAt(1)).isEqualTo("item3")
assertThat(model.getChosenItems()).containsExactly("item1", "item3")
}
@Test
fun `test retainChosenAndUpdate with no chosen items replaces all`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3"))
model.retainChosenAndUpdate(listOf("item4", "item5"))
assertThat(model.size).isEqualTo(2)
assertThat(model.getElementAt(0)).isEqualTo("item4")
assertThat(model.getElementAt(1)).isEqualTo("item5")
}
@Test
fun `test retainChosenAndUpdate fires appropriate events when growing`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2"))
var contentsChangedFired = false
var intervalAddedFired = false
model.addListDataListener(object : ListDataListener {
override fun intervalAdded(e: ListDataEvent) {
intervalAddedFired = true
}
override fun intervalRemoved(e: ListDataEvent) {}
override fun contentsChanged(e: ListDataEvent) {
contentsChangedFired = true
}
})
model.retainChosenAndUpdate(listOf("item3", "item4", "item5"))
assertThat(contentsChangedFired).isTrue()
assertThat(intervalAddedFired).isTrue()
}
@Test
fun `test retainChosenAndUpdate fires appropriate events when shrinking`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3", "item4"))
var contentsChangedFired = false
var intervalRemovedFired = false
model.addListDataListener(object : ListDataListener {
override fun intervalAdded(e: ListDataEvent) {}
override fun intervalRemoved(e: ListDataEvent) {
intervalRemovedFired = true
}
override fun contentsChanged(e: ListDataEvent) {
contentsChangedFired = true
}
})
model.retainChosenAndUpdate(listOf("itemA"))
assertThat(contentsChangedFired).isTrue()
assertThat(intervalRemovedFired).isTrue()
}
@Test
fun `test retainChosenAndUpdate preserves chosen state`() {
val model = MultiChooserListModel<String>()
model.add(listOf("item1", "item2", "item3"))
model.setChosen(listOf("item1", "item2"))
model.retainChosenAndUpdate(listOf("item1", "item4"))
assertThat(model.isChosen("item1")).isTrue()
assertThat(model.isChosen("item2")).isTrue()
assertThat(model.isChosen("item4")).isFalse()
}
}
@@ -130,7 +130,8 @@ merge.request.create.error.protected.branch=Commits will be pushed to a protecte
merge.request.create.progress.text=Creating merge request\u2026
merge.request.create.status.already.exists.view=View
merge.request.create.status.already.exists=Merge Request already exists
merge.request.create.reviewers.label={0,choice,0#Add reviewers|1#Reviewer|2#Reviewers}
merge.request.create.no.reviewers=No reviewers
merge.request.create.reviewers=Reviewers:
merge.request.create.tab.title=New Merge Request at {0}
merge.request.create.title.placeholder=Title
merge.request.create.description.placeholder=Description
@@ -3,6 +3,7 @@ package org.jetbrains.plugins.gitlab.mergerequest.ui.create
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.ui.CollaborationToolsUIUtil
import com.intellij.collaboration.ui.LabeledListComponentsFactory
import com.intellij.collaboration.ui.SingleValueModel
import com.intellij.collaboration.ui.bindValueIn
import com.intellij.collaboration.ui.codereview.commits.CommitsBrowserComponentBuilder
@@ -50,7 +51,7 @@ internal object GitLabMergeRequestCreateComponentFactory {
val directionSelector = createDirectionSelector(directionModel)
val commitsLoadingPanel = createCommitsPanel(project, cs, createVm)
val textPanel = cs.createTextPanel(project, createVm)
val reviewersPanel = GitLabMergeRequestCreateReviewersComponentFactory.create(cs, createVm)
val metadataPanel = createMetadataPanel(createVm)
val statusPanel = GitLabMergeRequestCreateStatusComponentFactory.create(cs, createVm)
val actionsPanel = GitLabMergeRequestCreateActionsComponentFactory.create(project, cs, createVm)
@@ -60,7 +61,7 @@ internal object GitLabMergeRequestCreateComponentFactory {
.addSeparator()
.addComponent(textPanel, zeroMinWidth = true, stretchYWithWeight = 0.3f, withoutBorder = true)
.addSeparator()
.addComponent(reviewersPanel, zeroMinWidth = true)
.addComponent(metadataPanel, zeroMinWidth = true)
.addSeparator()
.addComponent(statusPanel)
.addComponent(actionsPanel, withListBackground = false)
@@ -140,4 +141,12 @@ internal object GitLabMergeRequestCreateComponentFactory {
return CollaborationToolsUIUtil.wrapWithProgressStripe(cs, createVm.commits.map { it == null }, commitsPanel)
}
private fun createMetadataPanel(createVm: GitLabMergeRequestCreateViewModel): JComponent {
val lists = buildList {
add(GitLabMergeRequestCreateReviewersComponentFactory.createReviewersListPanelHandle(createVm))
}
return LabeledListComponentsFactory.createGrid(lists)
}
}
@@ -1,64 +1,64 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2025 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.ui.ComponentListPanelFactory
import com.intellij.collaboration.ui.HorizontalListPanel
import com.intellij.collaboration.ui.VerticalListPanel
import com.intellij.collaboration.async.mapState
import com.intellij.collaboration.ui.LabeledListComponentsFactory
import com.intellij.collaboration.ui.codereview.avatar.Avatar
import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentUIUtil
import com.intellij.collaboration.ui.codereview.details.CodeReviewDetailsStatusComponentFactory
import com.intellij.collaboration.ui.util.bindTextIn
import com.intellij.collaboration.ui.codereview.list.search.ShowDirection
import com.intellij.collaboration.ui.icon.IconsProvider
import com.intellij.ui.awt.RelativePoint
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO
import org.jetbrains.plugins.gitlab.mergerequest.ui.create.model.GitLabMergeRequestCreateViewModel
import org.jetbrains.plugins.gitlab.mergerequest.util.GitLabMergeRequestChoosersUtil
import org.jetbrains.plugins.gitlab.util.GitLabBundle
import javax.swing.JComponent
import javax.swing.JLabel
import javax.swing.SwingConstants
internal object GitLabMergeRequestCreateReviewersComponentFactory {
private const val HEADER_GAP = 5
private const val REVIEWERS_GAP = 7
private const val REVIEWER_PRESENTATION_GAP = 8
private const val COMPONENTS_GAP = 5
fun create(cs: CoroutineScope, createVm: GitLabMergeRequestCreateViewModel): JComponent {
val header = HorizontalListPanel(gap = HEADER_GAP).apply {
val reviewersLabel = JLabel().apply {
foreground = UIUtil.getContextHelpForeground()
bindTextIn(cs, createVm.adjustedReviewers.map { reviewers ->
GitLabBundle.message("merge.request.create.reviewers.label", reviewers.size)
})
}
val editButton = CodeReviewCommentUIUtil.createEditButton { event ->
val parentComponent = event.source as? JComponent ?: return@createEditButton
val point = RelativePoint.getSouthWestOf(parentComponent)
createVm.adjustReviewer(point)
}
add(reviewersLabel)
add(editButton)
}
val reviewersComponent = ComponentListPanelFactory.createVertical(
cs,
createVm.adjustedReviewers,
gap = REVIEWERS_GAP,
componentFactory = { reviewer -> createReviewerComponent(createVm, reviewer) }
fun createReviewersListPanelHandle(
vm: GitLabMergeRequestCreateViewModel,
): Pair<JComponent, JComponent> {
val label = LabeledListComponentsFactory.createLabelPanel(
vm.reviewers.mapState { it.isEmpty() },
GitLabBundle.message("merge.request.create.no.reviewers"),
GitLabBundle.message("merge.request.create.reviewers")
)
return VerticalListPanel(gap = COMPONENTS_GAP).apply {
add(header)
add(reviewersComponent)
}
val list = LabeledListComponentsFactory.createListPanel(
vm.reviewers,
{ comp, _ -> chooseReviewers(comp, vm, vm.avatarIconProvider) },
{ UserLabel(it, vm.avatarIconProvider) }
)
return label to list
}
private fun createReviewerComponent(createVm: GitLabMergeRequestCreateViewModel, reviewer: GitLabUserDTO): JComponent {
return CodeReviewDetailsStatusComponentFactory.ReviewDetailsStatusLabel("GitLab create MR: reviewer").apply {
iconTextGap = REVIEWER_PRESENTATION_GAP
icon = createVm.avatarIconProvider.getIcon(reviewer, Avatar.Sizes.BASE)
text = reviewer.name
private suspend fun chooseReviewers(
parentComponent: JComponent,
vm: GitLabMergeRequestCreateViewModel,
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
) {
val point = RelativePoint.getNorthEastOf(parentComponent)
val allowsMultiple = vm.allowsMultipleReviewers.value
val currentReviewers = vm.reviewers.value
val newList = if (allowsMultiple) {
GitLabMergeRequestChoosersUtil.chooseUsers(point, currentReviewers, vm.projectMembers, avatarIconsProvider, ShowDirection.ABOVE)
}
else {
val reviewer =
GitLabMergeRequestChoosersUtil.chooseUser(point, vm.projectMembers, avatarIconsProvider, ShowDirection.ABOVE)
listOfNotNull(reviewer)
}
vm.setReviewers(newList)
}
}
@Suppress("FunctionName")
private fun UserLabel(user: GitLabUserDTO, avatarIconsProvider: IconsProvider<GitLabUserDTO>) =
JLabel(user.name, avatarIconsProvider.getIcon(user, Avatar.Sizes.BASE), SwingConstants.LEFT).apply {
border = JBUI.Borders.empty(UIUtil.DEFAULT_VGAP, UIUtil.DEFAULT_HGAP / 2)
}
}
@@ -5,7 +5,6 @@ import com.intellij.collaboration.api.HttpStatusErrorException
import com.intellij.collaboration.async.childScope
import com.intellij.collaboration.async.combineAndCollect
import com.intellij.collaboration.async.extensionListFlow
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.async.mapNullableScoped
import com.intellij.collaboration.async.modelFlow
import com.intellij.collaboration.async.stateInNow
@@ -13,12 +12,13 @@ import com.intellij.collaboration.async.withInitial
import com.intellij.collaboration.ui.ListenableProgressIndicator
import com.intellij.collaboration.ui.codereview.create.CodeReviewTitleDescriptionViewModel
import com.intellij.collaboration.ui.icon.IconsProvider
import com.intellij.collaboration.util.IncrementallyComputedValue
import com.intellij.collaboration.util.ResultUtil.runCatchingUser
import com.intellij.collaboration.util.SingleCoroutineLauncher
import com.intellij.collaboration.util.collectIncrementallyTo
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.awt.RelativePoint
import com.intellij.vcs.log.Hash
import com.intellij.vcs.log.VcsCommitMetadata
import git4idea.GitBranch
@@ -44,7 +44,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@@ -55,9 +55,7 @@ import org.jetbrains.plugins.gitlab.GitLabProjectsManager
import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabMergeRequestState
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabProject
import org.jetbrains.plugins.gitlab.mergerequest.util.GitLabMergeRequestReviewersUtil
import org.jetbrains.plugins.gitlab.util.GitLabBundle
import org.jetbrains.plugins.gitlab.util.GitLabCoroutineUtil
import org.jetbrains.plugins.gitlab.util.GitLabProjectMapping
import org.jetbrains.plugins.gitlab.util.GitLabStatistics
@@ -70,7 +68,7 @@ internal interface GitLabMergeRequestCreateViewModel : CodeReviewTitleDescriptio
val isBusy: Flow<Boolean>
val allowsMultipleReviewers: Flow<Boolean>
val allowsMultipleReviewers: StateFlow<Boolean>
val branchState: Flow<BranchState?>
val existingMergeRequest: Flow<String?>
@@ -80,14 +78,14 @@ internal interface GitLabMergeRequestCreateViewModel : CodeReviewTitleDescriptio
val reviewRequirementsErrorState: Flow<MergeRequestRequirementsErrorType?>
val reviewCreatingError: Flow<Throwable?>
val potentialReviewers: Flow<Result<List<GitLabUserDTO>>>
val adjustedReviewers: StateFlow<List<GitLabUserDTO>>
val projectMembers: StateFlow<IncrementallyComputedValue<List<GitLabUserDTO>>>
val reviewers: StateFlow<List<GitLabUserDTO>>
val openReviewTabAction: suspend (mrIid: String) -> Unit
fun updateBranchState(state: BranchState?)
fun adjustReviewer(point: RelativePoint)
fun setReviewers(reviewers: List<GitLabUserDTO>)
fun createMergeRequest()
}
@@ -107,14 +105,14 @@ internal class GitLabMergeRequestCreateViewModelImpl(
override val isBusy: Flow<Boolean> = taskLauncher.busy
override val allowsMultipleReviewers: Flow<Boolean> = suspend {
override val allowsMultipleReviewers: StateFlow<Boolean> = suspend {
try {
projectData.isMultipleReviewersAllowed()
}
catch (e: Exception) {
catch (_: Exception) {
false
}
}.asFlow()
}.asFlow().stateIn(cs, SharingStarted.Eagerly, false)
private val listenableProgressIndicator = ListenableProgressIndicator()
override val creatingProgressText: Flow<String?> = callbackFlow {
@@ -183,11 +181,13 @@ internal class GitLabMergeRequestCreateViewModelImpl(
private val _reviewCreatingError: MutableStateFlow<Throwable?> = MutableStateFlow(null)
override val reviewCreatingError: StateFlow<Throwable?> = _reviewCreatingError.asStateFlow()
override val potentialReviewers: Flow<Result<List<GitLabUserDTO>>> =
GitLabCoroutineUtil.batchesResultsFlow(projectData.dataReloadSignal, projectData::getMembersBatches)
override val projectMembers: StateFlow<IncrementallyComputedValue<List<GitLabUserDTO>>> =
projectData.dataReloadSignal.withInitial(Unit).transformLatest {
projectData.getMembersBatches().collectIncrementallyTo(this)
}.stateIn(cs, SharingStarted.Lazily, IncrementallyComputedValue.loading())
private val _adjustedReviewers: MutableStateFlow<List<GitLabUserDTO>> = MutableStateFlow(listOf())
override val adjustedReviewers: StateFlow<List<GitLabUserDTO>> = _adjustedReviewers.asStateFlow()
private val _reviewers: MutableStateFlow<List<GitLabUserDTO>> = MutableStateFlow(listOf())
override val reviewers: StateFlow<List<GitLabUserDTO>> = _reviewers.asStateFlow()
private val _title: MutableStateFlow<String> = MutableStateFlow("")
override val titleText: StateFlow<String> = _title.asStateFlow()
@@ -249,20 +249,9 @@ internal class GitLabMergeRequestCreateViewModelImpl(
_branchState.value = state
}
override fun adjustReviewer(point: RelativePoint) {
cs.launchNow(Dispatchers.Main) {
val allowsMultipleReviewers = allowsMultipleReviewers.first()
val updatedReviewers = if (allowsMultipleReviewers) {
GitLabMergeRequestReviewersUtil.selectReviewers(point, adjustedReviewers.value, potentialReviewers, avatarIconProvider)
}
else {
GitLabMergeRequestReviewersUtil.selectReviewer(point, potentialReviewers, avatarIconProvider)?.let { listOfNotNull(it) }
}
updatedReviewers ?: return@launchNow
_adjustedReviewers.value = updatedReviewers
GitLabStatistics.logMrCreationReviewersAdjusted(project)
}
override fun setReviewers(reviewers: List<GitLabUserDTO>) {
_reviewers.value = reviewers
GitLabStatistics.logMrCreationReviewersAdjusted(project)
}
override fun createMergeRequest() {
@@ -277,7 +266,7 @@ internal class GitLabMergeRequestCreateViewModelImpl(
targetBranch = baseBranch.nameForRemoteOperations,
title = titleText.value.ifBlank { gitRemoteBranch.nameForRemoteOperations },
description = descriptionText.value.ifBlank { null },
reviewers = adjustedReviewers.value
reviewers = reviewers.value
)
openReviewTabAction(mergeRequest.iid)
onReviewCreated()
@@ -45,7 +45,7 @@ import org.jetbrains.plugins.gitlab.mergerequest.ui.review.GitLabMergeRequestSub
import org.jetbrains.plugins.gitlab.mergerequest.ui.review.GitLabMergeRequestSubmitReviewViewModel.SubmittableReview
import org.jetbrains.plugins.gitlab.mergerequest.ui.review.GitLabMergeRequestSubmitReviewViewModelImpl
import org.jetbrains.plugins.gitlab.mergerequest.ui.review.getSubmittableReview
import org.jetbrains.plugins.gitlab.mergerequest.util.GitLabMergeRequestReviewersUtil
import org.jetbrains.plugins.gitlab.mergerequest.util.GitLabMergeRequestChoosersUtil
import org.jetbrains.plugins.gitlab.util.GitLabBundle
import org.jetbrains.plugins.gitlab.util.GitLabCoroutineUtil
@@ -275,10 +275,10 @@ internal class GitLabMergeRequestReviewFlowViewModelImpl(
scope.launchNow(Dispatchers.Main) {
val allowsMultipleReviewers = allowsMultipleReviewers.first()
val updatedReviewers = if (allowsMultipleReviewers) {
GitLabMergeRequestReviewersUtil.selectReviewers(point, reviewers.value, potentialReviewers, avatarIconsProvider)
GitLabMergeRequestChoosersUtil.chooseUsers(point, reviewers.value, potentialReviewers, avatarIconsProvider)
}
else {
GitLabMergeRequestReviewersUtil.selectReviewer(point, potentialReviewers, avatarIconsProvider)?.let { listOfNotNull(it) }
GitLabMergeRequestChoosersUtil.chooseUser(point, potentialReviewers, avatarIconsProvider)?.let { listOfNotNull(it) }
}
updatedReviewers ?: return@launchNow
@@ -0,0 +1,100 @@
// 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.util
import com.intellij.collaboration.ui.codereview.avatar.Avatar
import com.intellij.collaboration.ui.codereview.list.search.ChooserPopupUtil
import com.intellij.collaboration.ui.codereview.list.search.PopupConfig
import com.intellij.collaboration.ui.codereview.list.search.ShowDirection
import com.intellij.collaboration.ui.icon.IconsProvider
import com.intellij.collaboration.ui.util.popup.PopupItemPresentation
import com.intellij.collaboration.util.IncrementallyComputedValue
import com.intellij.ui.awt.RelativePoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO
internal object GitLabMergeRequestChoosersUtil {
suspend fun chooseUser(
point: RelativePoint,
users: Flow<Result<List<GitLabUserDTO>>>,
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
): GitLabUserDTO? {
return ChooserPopupUtil.showAsyncChooserPopup(
point,
users,
presenter = { reviewer ->
PopupItemPresentation.Simple(
reviewer.username,
avatarIconsProvider.getIcon(reviewer, Avatar.Sizes.BASE),
reviewer.name,
)
}
)
}
suspend fun chooseUsers(
point: RelativePoint,
chosenUsers: List<GitLabUserDTO>,
users: Flow<Result<List<GitLabUserDTO>>>,
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
): List<GitLabUserDTO> {
val usersBatch = flow {
val batch = users.first()
emit(batch)
}
return ChooserPopupUtil.showAsyncMultipleChooserPopup(
point,
chosenUsers,
usersBatch,
presenter = { reviewer ->
PopupItemPresentation.Simple(
reviewer.username,
avatarIconsProvider.getIcon(reviewer, Avatar.Sizes.BASE),
reviewer.name,
)
}
)
}
suspend fun chooseUser(
point: RelativePoint,
users: StateFlow<IncrementallyComputedValue<List<GitLabUserDTO>>>,
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
showDirection: ShowDirection = ShowDirection.BELOW,
): GitLabUserDTO? =
ChooserPopupUtil.showChooserPopupWithIncrementalLoading(
point,
users,
presenter = { reviewer ->
PopupItemPresentation.Simple(
reviewer.username,
avatarIconsProvider.getIcon(reviewer, Avatar.Sizes.BASE),
reviewer.name,
)
},
PopupConfig.DEFAULT.copy(showDirection = showDirection)
)
suspend fun chooseUsers(
point: RelativePoint,
choseUsers: List<GitLabUserDTO>,
users: StateFlow<IncrementallyComputedValue<List<GitLabUserDTO>>>,
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
showDirection: ShowDirection = ShowDirection.BELOW,
): List<GitLabUserDTO> =
ChooserPopupUtil.showMultipleChooserPopupWithIncrementalLoading(
point,
choseUsers,
users,
presenter = { reviewer ->
PopupItemPresentation.Simple(
reviewer.username,
avatarIconsProvider.getIcon(reviewer, Avatar.Sizes.BASE),
reviewer.name,
)
},
PopupConfig.DEFAULT.copy(showDirection = showDirection)
)
}
@@ -1,56 +0,0 @@
// 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.util
import com.intellij.collaboration.ui.codereview.avatar.Avatar
import com.intellij.collaboration.ui.codereview.list.search.ChooserPopupUtil
import com.intellij.collaboration.ui.icon.IconsProvider
import com.intellij.collaboration.ui.util.popup.PopupItemPresentation
import com.intellij.ui.awt.RelativePoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO
internal object GitLabMergeRequestReviewersUtil {
suspend fun selectReviewer(
point: RelativePoint,
potentialReviewers: Flow<Result<List<GitLabUserDTO>>>,
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
): GitLabUserDTO? {
return ChooserPopupUtil.showAsyncChooserPopup(
point,
potentialReviewers,
presenter = { reviewer ->
PopupItemPresentation.Simple(
reviewer.username,
avatarIconsProvider.getIcon(reviewer, Avatar.Sizes.BASE),
reviewer.name,
)
}
)
}
suspend fun selectReviewers(
point: RelativePoint,
originalReviewers: List<GitLabUserDTO>,
potentialReviewers: Flow<Result<List<GitLabUserDTO>>>,
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
): List<GitLabUserDTO> {
val potentialReviewersBatch = flow {
val batch = potentialReviewers.first()
emit(batch)
}
return ChooserPopupUtil.showAsyncMultipleChooserPopup(
point,
originalReviewers,
potentialReviewersBatch,
presenter = { reviewer ->
PopupItemPresentation.Simple(
reviewer.username,
avatarIconsProvider.getIcon(reviewer, Avatar.Sizes.BASE),
reviewer.name,
)
}
)
}
}