diff --git a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/list/search/ChooserPopupUtil.kt b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/list/search/ChooserPopupUtil.kt index 41cf9a4bf038..99948da5e366 100644 --- a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/list/search/ChooserPopupUtil.kt +++ b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/list/search/ChooserPopupUtil.kt @@ -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 showMultipleChooserPopupWithIncrementalLoading( + point: RelativePoint, + currentItems: List, + listState: StateFlow>>, + presenter: (T) -> PopupItemPresentation, + popupConfig: PopupConfig = PopupConfig.DEFAULT, + ): List { + val listModel = MultiChooserListModel().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. * diff --git a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/list/search/MultiChooserListModel.kt b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/list/search/MultiChooserListModel.kt index a898f36e7918..3173e96fef51 100644 --- a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/list/search/MultiChooserListModel.kt +++ b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/list/search/MultiChooserListModel.kt @@ -57,4 +57,67 @@ internal class MultiChooserListModel : AbstractListModel() { fireIntervalAdded(this, 0, lastIndex + count) } } + + /** + * Updates the list of items, ensuring that chosen items stay in the list + */ + fun retainChosenAndUpdate(newList: List) { + 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) + } + } } \ No newline at end of file diff --git a/platform/collaboration-tools/test/com/intellij/collaboration/ui/codereview/list/search/MultiChooserListModelTest.kt b/platform/collaboration-tools/test/com/intellij/collaboration/ui/codereview/list/search/MultiChooserListModelTest.kt index 4c206f4b3f9c..51341387160a 100644 --- a/platform/collaboration-tools/test/com/intellij/collaboration/ui/codereview/list/search/MultiChooserListModelTest.kt +++ b/platform/collaboration-tools/test/com/intellij/collaboration/ui/codereview/list/search/MultiChooserListModelTest.kt @@ -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() + 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() + 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() + 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() + 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() + + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + } } \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/resources/messages/GitLabBundle.properties b/plugins/gitlab/gitlab-core/resources/messages/GitLabBundle.properties index b57f79306f7b..9018db2cf02f 100644 --- a/plugins/gitlab/gitlab-core/resources/messages/GitLabBundle.properties +++ b/plugins/gitlab/gitlab-core/resources/messages/GitLabBundle.properties @@ -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 diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateComponentFactory.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateComponentFactory.kt index 69e335049825..ceee97037582 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateComponentFactory.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateComponentFactory.kt @@ -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) + } } \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateReviewersComponentFactory.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateReviewersComponentFactory.kt index ba6b27532dbe..4b10195c28df 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateReviewersComponentFactory.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/GitLabMergeRequestCreateReviewersComponentFactory.kt @@ -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 { + 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, + ) { + 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) = + JLabel(user.name, avatarIconsProvider.getIcon(user, Avatar.Sizes.BASE), SwingConstants.LEFT).apply { + border = JBUI.Borders.empty(UIUtil.DEFAULT_VGAP, UIUtil.DEFAULT_HGAP / 2) } -} \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateViewModel.kt index 2edc7b77e979..b686f1acd03a 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/create/model/GitLabMergeRequestCreateViewModel.kt @@ -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 - val allowsMultipleReviewers: Flow + val allowsMultipleReviewers: StateFlow val branchState: Flow val existingMergeRequest: Flow @@ -80,14 +78,14 @@ internal interface GitLabMergeRequestCreateViewModel : CodeReviewTitleDescriptio val reviewRequirementsErrorState: Flow val reviewCreatingError: Flow - val potentialReviewers: Flow>> - val adjustedReviewers: StateFlow> + val projectMembers: StateFlow>> + val reviewers: StateFlow> val openReviewTabAction: suspend (mrIid: String) -> Unit fun updateBranchState(state: BranchState?) - fun adjustReviewer(point: RelativePoint) + fun setReviewers(reviewers: List) fun createMergeRequest() } @@ -107,14 +105,14 @@ internal class GitLabMergeRequestCreateViewModelImpl( override val isBusy: Flow = taskLauncher.busy - override val allowsMultipleReviewers: Flow = suspend { + override val allowsMultipleReviewers: StateFlow = 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 = callbackFlow { @@ -183,11 +181,13 @@ internal class GitLabMergeRequestCreateViewModelImpl( private val _reviewCreatingError: MutableStateFlow = MutableStateFlow(null) override val reviewCreatingError: StateFlow = _reviewCreatingError.asStateFlow() - override val potentialReviewers: Flow>> = - GitLabCoroutineUtil.batchesResultsFlow(projectData.dataReloadSignal, projectData::getMembersBatches) + override val projectMembers: StateFlow>> = + projectData.dataReloadSignal.withInitial(Unit).transformLatest { + projectData.getMembersBatches().collectIncrementallyTo(this) + }.stateIn(cs, SharingStarted.Lazily, IncrementallyComputedValue.loading()) - private val _adjustedReviewers: MutableStateFlow> = MutableStateFlow(listOf()) - override val adjustedReviewers: StateFlow> = _adjustedReviewers.asStateFlow() + private val _reviewers: MutableStateFlow> = MutableStateFlow(listOf()) + override val reviewers: StateFlow> = _reviewers.asStateFlow() private val _title: MutableStateFlow = MutableStateFlow("") override val titleText: StateFlow = _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) { + _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() diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/details/model/GitLabMergeRequestReviewFlowViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/details/model/GitLabMergeRequestReviewFlowViewModel.kt index efe0608d6ca1..92194fdf89f8 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/details/model/GitLabMergeRequestReviewFlowViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/details/model/GitLabMergeRequestReviewFlowViewModel.kt @@ -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 diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/util/GitLabMergeRequestChoosersUtil.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/util/GitLabMergeRequestChoosersUtil.kt new file mode 100644 index 000000000000..0141d275c76d --- /dev/null +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/util/GitLabMergeRequestChoosersUtil.kt @@ -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>>, + avatarIconsProvider: IconsProvider, + ): 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, + users: Flow>>, + avatarIconsProvider: IconsProvider, + ): List { + 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>>, + avatarIconsProvider: IconsProvider, + 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, + users: StateFlow>>, + avatarIconsProvider: IconsProvider, + showDirection: ShowDirection = ShowDirection.BELOW, + ): List = + ChooserPopupUtil.showMultipleChooserPopupWithIncrementalLoading( + point, + choseUsers, + users, + presenter = { reviewer -> + PopupItemPresentation.Simple( + reviewer.username, + avatarIconsProvider.getIcon(reviewer, Avatar.Sizes.BASE), + reviewer.name, + ) + }, + PopupConfig.DEFAULT.copy(showDirection = showDirection) + ) +} \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/util/GitLabMergeRequestReviewersUtil.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/util/GitLabMergeRequestReviewersUtil.kt deleted file mode 100644 index 8a2b4a7138ba..000000000000 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/util/GitLabMergeRequestReviewersUtil.kt +++ /dev/null @@ -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>>, - avatarIconsProvider: IconsProvider, - ): 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, - potentialReviewers: Flow>>, - avatarIconsProvider: IconsProvider, - ): List { - 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, - ) - } - ) - } -} \ No newline at end of file