[github] implement a more modern changes browser for PR creation view

GitOrigin-RevId: 99cb0f37597ff158a0d3b4579a1fbd90fcbd6db4
This commit is contained in:
Ivan Semenov
2024-06-24 15:08:44 +02:00
committed by intellij-monorepo-bot
parent 4fd57d4e4a
commit 5f6f276e8c
17 changed files with 579 additions and 53 deletions

View File

@@ -1,13 +1,16 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.collaboration.ui.codereview.details.model
import com.intellij.collaboration.ui.SimpleEventListener
import com.intellij.collaboration.ui.codereview.details.model.CodeReviewChangeListViewModel.SelectionRequest
import com.intellij.collaboration.util.ChangesSelection
import com.intellij.collaboration.util.RefComparisonChange
import com.intellij.openapi.actionSystem.DataKey
import com.intellij.openapi.project.Project
import com.intellij.platform.util.coroutines.childScope
import com.intellij.util.EventDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.*
import org.jetbrains.annotations.ApiStatus
import java.util.concurrent.locks.ReentrantLock
@@ -85,6 +88,7 @@ abstract class CodeReviewChangeListViewModelBase(
private val _changesSelection = MutableStateFlow<ChangesSelection?>(null)
override val changesSelection: StateFlow<ChangesSelection?> = _changesSelection.asStateFlow()
private val selectionMulticaster = EventDispatcher.create(SimpleEventListener::class.java)
protected val selectedCommit: String? = changeList.commitSha
@@ -99,6 +103,7 @@ abstract class CodeReviewChangeListViewModelBase(
_selectionRequests.tryEmit(SelectionRequest.All)
}
else {
if (!changeList.changes.contains(change)) return
val currentSelection = _changesSelection.value
if (currentSelection == null || currentSelection !is ChangesSelection.Fuzzy || !currentSelection.changes.contains(change)) {
_changesSelection.value = ChangesSelection.Precise(changeList.changes, change)
@@ -116,9 +121,25 @@ abstract class CodeReviewChangeListViewModelBase(
if (!stateGuard.tryLock()) return
try {
_changesSelection.value = selection
selectionMulticaster.multicaster.eventOccurred()
}
finally {
stateGuard.unlock()
}
}
/**
* Listener invoked SYNCHRONOUSLY when selection is changed
*/
suspend fun handleSelection(listener: (ChangesSelection?) -> Unit): Nothing {
val simpleListener = SimpleEventListener { listener(changesSelection.value) }
try {
selectionMulticaster.addListener(simpleListener)
listener(changesSelection.value)
awaitCancellation()
}
finally {
selectionMulticaster.removeListener(simpleListener)
}
}
}

View File

@@ -0,0 +1,90 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.collaboration.ui.codereview.details.model
import com.intellij.collaboration.async.mapState
import com.intellij.platform.util.coroutines.childScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.updateAndGet
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
@ApiStatus.NonExtendable
interface CodeReviewCommitsChangesStateHandler<C : Any, VM : Any> {
val selectedCommit: StateFlow<C?>
val changeListVm: StateFlow<VM>
fun selectCommit(index: Int): VM?
fun selectCommit(commit: C?): VM?
fun selectNextCommit(): VM?
fun selectPreviousCommit(): VM?
companion object {
@JvmOverloads
fun <C : Any, VM : Any> create(
cs: CoroutineScope,
commits: List<C>,
commitChangesVmProducer: CoroutineScope.(C?) -> VM,
initialCommitIdx: Int = -1,
): CodeReviewCommitsChangesStateHandler<C, VM> =
CodeReviewCommitsChangesStateHandlerImpl(cs, commits, commitChangesVmProducer, initialCommitIdx)
}
}
private class CodeReviewCommitsChangesStateHandlerImpl<C : Any, VM : Any>(
private val cs: CoroutineScope,
private val commits: List<C>,
private val commitChangesVmProducer: CoroutineScope.(C?) -> VM,
initialCommitIdx: Int = -1,
) : CodeReviewCommitsChangesStateHandler<C, VM> {
private val state = MutableStateFlow(createState(initialCommitIdx))
override val selectedCommit: StateFlow<C?> = state.mapState { commits.getOrNull(it.commitIdx) }
override val changeListVm: StateFlow<VM> = state.mapState { it.vm }
override fun selectCommit(index: Int): VM? {
if (index > 0 && index !in commits.indices) return null
return state.updateAndGet {
it.changeCommit(index)
}.vm
}
override fun selectCommit(commit: C?): VM? {
val idx = commits.indexOf(commit).takeIf { it >= 0 } ?: return null
return state.updateAndGet {
it.changeCommit(idx)
}.vm
}
override fun selectNextCommit(): VM? = state.updateAndGet {
val newIdx = it.commitIdx + 1
if (newIdx !in commits.indices) return null // return out of select, do not update
it.changeCommit(newIdx)
}.vm
override fun selectPreviousCommit(): VM? = state.updateAndGet {
val newIdx = it.commitIdx - 1
if (newIdx !in commits.indices) return null // return out of select, do not update
it.changeCommit(newIdx)
}.vm
private fun State.changeCommit(commitIdx: Int): State {
if (this.commitIdx == commitIdx) return this
vmCs.cancel()
return createState(commitIdx)
}
private fun createState(commitIdx: Int): State {
val newCs = cs.childScope("Commit Changes View Model")
val commit = commits.getOrNull(commitIdx)
return State(commitIdx, newCs, newCs.commitChangesVmProducer(commit))
}
private inner class State(
val commitIdx: Int,
val vmCs: CoroutineScope,
val vm: VM
)
}

View File

@@ -34,6 +34,11 @@ class CodeReviewDiffViewModelComputer<D> @ApiStatus.Experimental constructor(
changesRequests.emit(selection)
}
@ApiStatus.Internal
fun tryShowChanges(selection: ChangesSelection) {
changesRequests.tryEmit(selection)
}
val diffVm: Flow<ComputedResult<DiffProducersViewModel?>> =
dataLoadingFlow.mapNotNull { it.result }.distinctUntilChanged().mapScoped { result ->
result.fold(

View File

@@ -1,12 +1,16 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.collaboration.ui.codereview.diff.model
import com.intellij.collaboration.ui.SimpleEventListener
import com.intellij.collaboration.util.ComputedResult
import com.intellij.openapi.vcs.changes.ui.ChangeDiffRequestChain
import com.intellij.util.EventDispatcher
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.jetbrains.annotations.ApiStatus
import kotlin.math.max
import kotlin.math.min
@@ -30,6 +34,8 @@ class DiffProducersViewModel private constructor(initialState: State) {
private val _producers = MutableStateFlow(initialState)
val producers: StateFlow<State> = _producers.asStateFlow()
private val selectionMulticaster = EventDispatcher.create(SimpleEventListener::class.java)
internal fun canNavigate(): Boolean = producers.value.producers.size > 1
internal fun canSelectPrev(): Boolean = producers.value.selectedIdx > 0
@@ -46,6 +52,23 @@ class DiffProducersViewModel private constructor(initialState: State) {
internal fun updateProducers(newStateSupplier: (State) -> State) {
_producers.update(newStateSupplier)
selectionMulticaster.multicaster.eventOccurred()
}
/**
* Listener invoked SYNCHRONOUSLY when selection is changed
*/
@ApiStatus.Internal
suspend fun handleSelection(listener: (ChangeDiffRequestChain.Producer?) -> Unit): Nothing {
val simpleListener = SimpleEventListener { listener(producers.value.getSelected()) }
try {
selectionMulticaster.addListener(simpleListener)
listener(producers.value.getSelected())
awaitCancellation()
}
finally {
selectionMulticaster.removeListener(simpleListener)
}
}
data class State internal constructor(val producers: List<ChangeDiffRequestChain.Producer>, val selectedIdx: Int) {

View File

@@ -488,6 +488,10 @@ public final class GitChangeUtils {
}
}
/**
* @deprecated use getThreeDotDiffOrThrow
*/
@Deprecated
public static @NotNull Collection<Change> getThreeDotDiff(@NotNull GitRepository repository,
@NotNull @NonNls String oldRevision,
@NotNull @NonNls String newRevision) {
@@ -500,6 +504,12 @@ public final class GitChangeUtils {
}
}
public static @NotNull Collection<Change> getThreeDotDiffOrThrow(@NotNull GitRepository repository,
@NotNull @NonNls String oldRevision,
@NotNull @NonNls String newRevision) throws VcsException {
return getDiff(repository.getProject(), repository.getRoot(), oldRevision, newRevision, null, true, true);
}
public static class GitDiffChange implements FilePathChange {
private final @NotNull FileStatus status;
private final @Nullable FilePath beforePath;

View File

@@ -155,9 +155,10 @@ pull.request.create.no.changes=No changes between ''{0}'' and ''{1}''
pull.request.create.error=Failed to create a pull request
pull.request.create.already.exists=Pull request already exists
pull.request.create.already.exists.view=View
pull.request.create.select.branches=Selected pull request base and head to view changes
pull.request.create.select.branches=Select pull request base and head to view changes
pull.request.create.no.commits=No commits
pull.request.create.failed.to.load.commits=Failed to load commits
pull.request.create.failed.to.load.changes=Failed to load changes
pull.request.create.checking.branches=Checking pull request branches
pull.request.create.failed.to.check.branches=Failed to check pull request branches
pull.request.create.pushing=Pushing a pull request branch

View File

@@ -2,12 +2,16 @@
package org.jetbrains.plugins.github.pullrequest
import com.intellij.collaboration.file.codereview.CodeReviewDiffVirtualFile
import com.intellij.collaboration.ui.codereview.CodeReviewAdvancedSettings
import com.intellij.diff.impl.DiffEditorViewer
import com.intellij.diff.util.DiffUserDataKeysEx
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.vcs.editor.ComplexPathVirtualFileSystem
import org.jetbrains.plugins.github.api.GHRepositoryCoordinates
import org.jetbrains.plugins.github.i18n.GithubBundle
import org.jetbrains.plugins.github.pullrequest.data.GHPRDataContextRepository
import org.jetbrains.plugins.github.pullrequest.ui.diff.GHPRDiffService
internal data class GHNewPRDiffVirtualFile(private val fileManagerId: String,
private val project: Project,
@@ -23,7 +27,14 @@ internal data class GHNewPRDiffVirtualFile(private val fileManagerId: String,
override fun isValid(): Boolean = isFileValid(fileManagerId, project, repository)
override fun createViewer(project: Project): DiffEditorViewer {
TODO("Not implemented yet")
val processor = if (CodeReviewAdvancedSettings.isCombinedDiffEnabled()) {
project.service<GHPRDiffService>().createCombinedDiffProcessor(repository)
}
else {
project.service<GHPRDiffService>().createDiffRequestProcessor(repository)
}
processor.context.putUserData(DiffUserDataKeysEx.COMBINED_DIFF_TOGGLE, CodeReviewAdvancedSettings.CodeReviewCombinedDiffToggle)
return processor
}
}

View File

@@ -17,7 +17,7 @@ internal interface GHPRFilesManager : Disposable {
fun createAndOpenTimelineFile(pullRequest: GHPRIdentifier, requestFocus: Boolean)
@RequiresEdt
fun createAndOpenDiffFile(pullRequest: GHPRIdentifier, requestFocus: Boolean)
fun createAndOpenDiffFile(pullRequest: GHPRIdentifier?, requestFocus: Boolean)
@RequiresEdt
fun findTimelineFile(pullRequest: GHPRIdentifier): GHPRTimelineVirtualFile?
@@ -27,4 +27,6 @@ internal interface GHPRFilesManager : Disposable {
@RequiresEdt
fun updateTimelineFilePresentation(details: GHPullRequestShort)
suspend fun closeNewPrFile()
}

View File

@@ -4,11 +4,16 @@ package org.jetbrains.plugins.github.pullrequest.data
import com.intellij.collaboration.util.CodeReviewFilesUtil
import com.intellij.diff.editor.DiffEditorTabFilesManager
import com.intellij.diff.editor.DiffVirtualFileBase
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.writeAction
import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
import com.intellij.openapi.project.Project
import com.intellij.util.containers.ContainerUtil
import com.intellij.util.containers.addIfNotNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.plugins.github.api.GHRepositoryCoordinates
import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequestShort
import org.jetbrains.plugins.github.pullrequest.GHNewPRDiffVirtualFile
@@ -42,15 +47,22 @@ internal class GHPRFilesManagerImpl(private val project: Project,
}
}
override fun createAndOpenDiffFile(pullRequest: GHPRIdentifier, requestFocus: Boolean) {
diffFiles.getOrPut(pullRequest) {
GHPRDiffVirtualFile(id, project, repository, pullRequest)
override fun createAndOpenDiffFile(pullRequest: GHPRIdentifier?, requestFocus: Boolean) {
if (pullRequest == null) {
createOrGetNewPRDiffFile()
}
else {
diffFiles.getOrPut(pullRequest) {
GHPRDiffVirtualFile(id, project, repository, pullRequest)
}.also {
GHPRStatisticsCollector.logDiffOpened(project)
}
}.let {
DiffEditorTabFilesManager.getInstance(project).showDiffFile(it, requestFocus)
GHPRStatisticsCollector.logDiffOpened(project)
}
}
override fun findTimelineFile(pullRequest: GHPRIdentifier): GHPRTimelineVirtualFile? = files[pullRequest]
override fun findDiffFile(pullRequest: GHPRIdentifier): DiffVirtualFileBase? = diffFiles[pullRequest]
@@ -61,6 +73,16 @@ internal class GHPRFilesManagerImpl(private val project: Project,
}
}
override suspend fun closeNewPrFile() {
val file = newPRDiffFile.get() ?: return
withContext(Dispatchers.EDT) {
val fileManager = serviceAsync<FileEditorManager>()
writeAction {
CodeReviewFilesUtil.closeFilesSafely(fileManager, listOf(file))
}
}
}
override fun dispose() {
if (project.isDisposed) return
val fileManager = FileEditorManager.getInstance(project)

View File

@@ -1,6 +1,9 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.pullrequest.data.service
import com.intellij.collaboration.util.RefComparisonChange
import com.intellij.vcs.log.VcsCommitMetadata
import git4idea.GitBranch
import git4idea.GitRemoteBranch
import org.jetbrains.plugins.github.api.GHRepositoryPath
import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequestShort
@@ -18,4 +21,8 @@ interface GHPRCreationService {
suspend fun findOpenPullRequest(baseBranch: GitRemoteBranch?,
headRepo: GHRepositoryPath,
headBranch: GitRemoteBranch): GHPRIdentifier?
suspend fun getDiff(commit: VcsCommitMetadata): Collection<RefComparisonChange>
suspend fun getDiff(baseBranch: GitRemoteBranch, headBranch: GitBranch): Collection<RefComparisonChange>
}

View File

@@ -1,8 +1,22 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.plugins.github.pullrequest.data.service
import com.intellij.collaboration.util.RefComparisonChange
import com.intellij.openapi.progress.coroutineToIndicator
import com.intellij.openapi.vcs.changes.Change
import com.intellij.openapi.vcs.history.ShortVcsRevisionNumber
import com.intellij.util.asSafely
import com.intellij.util.text.nullize
import com.intellij.vcs.log.VcsCommitMetadata
import git4idea.GitBranch
import git4idea.GitRemoteBranch
import git4idea.GitRevisionNumber
import git4idea.changes.GitChangeUtils
import git4idea.history.GitCommitRequirements
import git4idea.history.GitHistoryUtils
import git4idea.history.GitLogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.plugins.github.api.*
import org.jetbrains.plugins.github.api.data.GithubIssueState
import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequestShort
@@ -46,4 +60,42 @@ internal class GHPRCreationServiceImpl(private val requestExecutor: GithubApiReq
private fun getHeadRepoPrefix(headRepo: GHGitRepositoryMapping) =
if (baseRepo.repository == headRepo.repository) "" else headRepo.repository.repositoryPath.owner + ":"
override suspend fun getDiff(commit: VcsCommitMetadata): Collection<RefComparisonChange> {
return withContext(Dispatchers.IO) {
coroutineToIndicator {
buildList {
GitLogUtil.readFullDetailsForHashes(baseRepo.gitRepository.project, baseRepo.gitRepository.root,
listOf(commit.id.asString()),
GitCommitRequirements.DEFAULT) { gitCommit ->
gitCommit.changes.forEach { change ->
add(change.toComparisonChange(commit.parents.first().asString(), commit.id.asString()))
}
}
}
}
}
}
override suspend fun getDiff(baseBranch: GitRemoteBranch, headBranch: GitBranch): Collection<RefComparisonChange> {
return withContext(Dispatchers.IO) {
coroutineToIndicator {
val mergeBase = GitHistoryUtils.getMergeBase(baseRepo.gitRepository.project, baseRepo.gitRepository.root,
baseBranch.name, headBranch.name)
?: error("Unrelated branches ${baseBranch.name} ${headBranch.name}")
val headRef = baseRepo.gitRepository.branches.getHash(headBranch) ?: error("Branch ${headBranch.name} has bo revision")
GitChangeUtils.getThreeDotDiffOrThrow(baseRepo.gitRepository, baseBranch.name, headBranch.name).map {
it.toComparisonChange(mergeBase.rev, headRef.asString())
}
}
}
}
private fun Change.toComparisonChange(commitBefore: String, commitAfter: String): RefComparisonChange {
val beforeRef = beforeRevision?.revisionNumber?.asSafely<ShortVcsRevisionNumber>() ?: GitRevisionNumber(commitBefore)
val afterRef = afterRevision?.revisionNumber?.asSafely<ShortVcsRevisionNumber>() ?: GitRevisionNumber(commitAfter)
return RefComparisonChange(
beforeRef, beforeRevision?.file, afterRef, afterRevision?.file
)
}
}

View File

@@ -4,6 +4,7 @@ package org.jetbrains.plugins.github.pullrequest.ui.diff
import com.intellij.collaboration.messages.CollaborationToolsBundle
import com.intellij.collaboration.ui.codereview.action.ImmutableToolbarLabelAction
import com.intellij.collaboration.ui.codereview.diff.CodeReviewDiffHandlerHelper
import com.intellij.collaboration.ui.codereview.diff.model.ComputedDiffViewModel
import com.intellij.collaboration.util.KeyValuePair
import com.intellij.diff.impl.DiffRequestProcessor
import com.intellij.diff.tools.combined.CombinedDiffComponentProcessor
@@ -17,10 +18,7 @@ import com.intellij.openapi.util.Disposer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.*
import org.jetbrains.plugins.github.api.GHRepositoryCoordinates
import org.jetbrains.plugins.github.pullrequest.comment.action.GHPRDiffReviewThreadsReloadAction
import org.jetbrains.plugins.github.pullrequest.data.GHPRIdentifier
@@ -53,6 +51,16 @@ internal class GHPRDiffService(private val project: Project, parentCs: Coroutine
GHPRDiffReviewThreadsReloadAction(),
ActionManager.getInstance().getAction("Github.PullRequest.Review.Submit"))))
}
fun createDiffRequestProcessor(repository: GHRepositoryCoordinates): DiffRequestProcessor {
val vm = findDiffVm(project, repository)
return base.createDiffRequestProcessor(vm) { emptyList() }
}
fun createCombinedDiffProcessor(repository: GHRepositoryCoordinates): CombinedDiffComponentProcessor {
val vm = findDiffVm(project, repository)
return base.createCombinedDiffModel(vm) { emptyList() }
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -66,6 +74,14 @@ private fun findDiffVm(project: Project, repository: GHRepositoryCoordinates, pu
}
} ?: flowOf(null)
private fun findDiffVm(project: Project, repository: GHRepositoryCoordinates): Flow<ComputedDiffViewModel?> =
project.serviceIfCreated<GHPRToolWindowViewModel>()?.projectVm?.map {
if (it?.repository == repository) {
it.getCreateVmOrNull()?.diffVm
}
else null
} ?: flowOf(null)
private fun GHPRToolWindowProjectViewModel.getDiffViewModelFlow(pullRequest: GHPRIdentifier): Flow<GHPRDiffViewModel> = channelFlow {
val acquisitionDisposable = Disposer.newDisposable()
val vm = acquireDiffViewModel(pullRequest, acquisitionDisposable)

View File

@@ -0,0 +1,112 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.github.pullrequest.ui.toolwindow.create
import com.intellij.collaboration.async.*
import com.intellij.collaboration.ui.codereview.details.model.*
import com.intellij.collaboration.util.ComputedResult
import com.intellij.collaboration.util.RefComparisonChange
import com.intellij.collaboration.util.map
import com.intellij.openapi.project.Project
import com.intellij.platform.util.coroutines.childScope
import com.intellij.vcs.log.VcsCommitMetadata
import git4idea.GitBranch
import git4idea.GitRemoteBranch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
import org.jetbrains.plugins.github.pullrequest.config.GithubPullRequestsProjectUISettings
import org.jetbrains.plugins.github.pullrequest.data.GHPRDataContext
internal class GHPRCreateChangesViewModel(
private val project: Project,
private val settings: GithubPullRequestsProjectUISettings,
parentCs: CoroutineScope,
private val dataContext: GHPRDataContext,
private val baseBranch: GitRemoteBranch,
private val headBranch: GitBranch,
private val commits: List<VcsCommitMetadata>,
) : CodeReviewChangesViewModel<VcsCommitMetadata> {
private val cs = parentCs.childScope(javaClass.name)
override val reviewCommits: StateFlow<List<VcsCommitMetadata>> = MutableStateFlow(commits)
private val stateHandler = CodeReviewCommitsChangesStateHandler.create(cs, commits, {
CommitChangesViewModel(this, it).apply {
selectChange(null)
}
})
override val selectedCommitIndex: StateFlow<Int> = stateHandler.selectedCommit.mapState { commits.indexOf(it) }
override val selectedCommit: StateFlow<VcsCommitMetadata?> = stateHandler.selectedCommit
val commitChangesVm: StateFlow<CommitChangesViewModel> = stateHandler.changeListVm
override fun selectCommit(index: Int) {
stateHandler.selectCommit(index)?.selectChange(null)
}
override fun selectNextCommit() {
stateHandler.selectNextCommit()?.selectChange(null)
}
override fun selectPreviousCommit() {
stateHandler.selectPreviousCommit()?.selectChange(null)
}
override fun commitHash(commit: VcsCommitMetadata): String = commit.id.toShortString()
inner class CommitChangesViewModel(cs: CoroutineScope, commit: VcsCommitMetadata?) {
private val changeSelectionRequests = MutableSharedFlow<RefComparisonChange?>(replay = 1)
val changeListVm: StateFlow<ComputedResult<CodeReviewChangeListViewModelBase>> = computationStateFlow(flowOf(Unit)) {
loadChanges(commit)
}.mapScoped { result ->
result.map {
createChangesVm(commit, it)
}
}.stateInNow(cs, ComputedResult.loading())
private fun CoroutineScope.createChangesVm(commit: VcsCommitMetadata?, changes: Collection<RefComparisonChange>) =
GHPRCreateChangeListViewModel(this, commit, changes.toList()).also { vm ->
launchNow {
changeSelectionRequests.collect {
vm.selectChange(it)
}
}
}
private suspend fun loadChanges(commit: VcsCommitMetadata?): Collection<RefComparisonChange> =
if (commit == null) {
dataContext.creationService.getDiff(baseBranch, headBranch)
}
else {
dataContext.creationService.getDiff(commit)
}
fun selectChange(change: RefComparisonChange?) {
changeSelectionRequests.tryEmit(change)
}
}
private inner class GHPRCreateChangeListViewModel(parentCs: CoroutineScope, commit: VcsCommitMetadata?, changes: List<RefComparisonChange>)
: CodeReviewChangeListViewModelBase(parentCs, CodeReviewChangeList(commit?.id?.asString(), changes)),
CodeReviewChangeListViewModel.WithGrouping {
override val grouping: StateFlow<Set<String>> = settings.changesGroupingState
override fun setGrouping(grouping: Collection<String>) {
settings.changesGrouping = grouping.toSet()
}
override val project: Project = this@GHPRCreateChangesViewModel.project
override fun showDiffPreview() {
dataContext.filesManager.createAndOpenDiffFile(null, true)
}
override fun showDiff() {
showDiffPreview() // implement
}
}
}

View File

@@ -6,20 +6,29 @@ import com.intellij.collaboration.async.collectScoped
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.ui.*
import com.intellij.collaboration.ui.CollaborationToolsUIUtil.defaultButton
import com.intellij.collaboration.ui.CollaborationToolsUIUtil.moveToCenter
import com.intellij.collaboration.ui.codereview.avatar.Avatar
import com.intellij.collaboration.ui.codereview.commits.CommitsBrowserComponentBuilder
import com.intellij.collaboration.ui.codereview.changes.CodeReviewChangeListComponentFactory
import com.intellij.collaboration.ui.codereview.create.CodeReviewCreateReviewUIUtil
import com.intellij.collaboration.ui.codereview.details.CodeReviewDetailsCommitInfoComponentFactory
import com.intellij.collaboration.ui.codereview.details.CodeReviewDetailsCommitsComponentFactory
import com.intellij.collaboration.ui.codereview.details.CommitPresentation
import com.intellij.collaboration.ui.codereview.details.model.CodeReviewChangeListViewModel
import com.intellij.collaboration.ui.codereview.list.error.ErrorStatusPanelFactory
import com.intellij.collaboration.ui.codereview.list.error.ErrorStatusPresenter
import com.intellij.collaboration.ui.layout.SizeRestrictedSingleComponentLayout
import com.intellij.collaboration.ui.util.*
import com.intellij.collaboration.util.*
import com.intellij.icons.AllIcons
import com.intellij.ide.DataManager
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.EdtNoGetDataProvider
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.messages.MessagesService
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.toolWindow.InternalDecoratorImpl
@@ -29,6 +38,7 @@ import com.intellij.ui.components.JBOptionButton
import com.intellij.ui.components.panels.Wrapper
import com.intellij.util.ui.*
import com.intellij.vcs.log.VcsCommitMetadata
import com.intellij.vcs.log.util.VcsUserUtil
import com.intellij.vcsUtil.showAbove
import git4idea.ui.branch.MergeDirectionComponentFactory
import kotlinx.coroutines.*
@@ -57,6 +67,7 @@ import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.awt.event.ContainerEvent
import java.awt.event.ContainerListener
import java.util.*
import javax.swing.JComponent
import javax.swing.JLabel
import javax.swing.JPanel
@@ -81,7 +92,7 @@ internal object GHPRCreateComponentFactory {
layout = MigLayout(LC().gridGap("0", "0").insets("0").flowY().fill())
add(directionSelector(vm), CC().pushX().gap(SIDE_GAPS_L, SIDE_GAPS_L, SIDE_GAPS_M, SIDE_GAPS_M))
add(commitsPanel(vm).apply {
add(changesPanel(vm).apply {
border = IdeBorderFactory.createBorder(SideBorder.TOP)
}, CC().grow().push())
}
@@ -222,34 +233,76 @@ internal object GHPRCreateComponentFactory {
}
@OptIn(FlowPreview::class)
private fun CoroutineScope.commitsPanel(vm: GHPRCreateViewModel): JComponent {
private fun CoroutineScope.changesPanel(vm: GHPRCreateViewModel): JComponent {
val wrapper = Wrapper()
val errorStatusPresenter = ErrorStatusPresenter.simple(message("pull.request.create.failed.to.load.commits"),
descriptionProvider = GHHtmlErrorPanel::getLoadingErrorText)
launchNow {
vm.commits.debounce(50).collect { commitsResult ->
val content = commitsResult?.fold(::LoadingTextLabel,
{ createCommitsPanel(vm.project, it) },
{
val errorStatusPresenter = ErrorStatusPresenter.simple(message("pull.request.create.failed.to.load.commits"),
descriptionProvider = GHHtmlErrorPanel::getLoadingErrorText)
ErrorStatusPanelFactory.create(it, errorStatusPresenter)
})
?: SimpleHtmlPane(message("pull.request.create.select.branches"))
vm.changesVm.debounce(50).collectScoped { changesResult ->
val content = changesResult?.fold({ moveToCenter(LoadingTextLabel()) },
{ createChangesPanel(it) },
{ moveToCenter(ErrorStatusPanelFactory.create(it, errorStatusPresenter)) })
?: SimpleHtmlPane(message("pull.request.create.select.branches")).let(CollaborationToolsUIUtil::moveToCenter)
wrapper.setContent(content)
}
}
return wrapper
}
private fun createCommitsPanel(project: Project, commits: List<VcsCommitMetadata>): JComponent {
val commitsModel = SingleValueModel(commits)
val commitsPanel = CommitsBrowserComponentBuilder(project, commitsModel)
.setCustomCommitRenderer(CodeReviewCreateReviewUIUtil.createCommitListCellRenderer())
.showCommitDetails(true)
.setEmptyCommitListText(message("pull.request.create.no.commits"))
.create()
return commitsPanel
private fun CoroutineScope.createChangesPanel(changesVm: GHPRCreateChangesViewModel?): JComponent {
if (changesVm == null) {
return HintPane(message("pull.request.does.not.contain.commits")).let(CollaborationToolsUIUtil::moveToCenter)
}
val cs = this
val commits = CodeReviewDetailsCommitsComponentFactory
.create(cs, changesVm, ::createCommitsPopupPresenter)
val commitsDetails = CodeReviewDetailsCommitInfoComponentFactory
.create(cs, changesVm.selectedCommit, ::createCommitsPopupPresenter, ::SimpleHtmlPane)
val commitsPanel = VerticalListPanel(SIDE_GAPS_M).apply {
add(commits)
add(commitsDetails)
border = JBUI.Borders.empty(SIDE_GAPS_M, SIDE_GAPS_L)
}
val scrollPane = ScrollPaneFactory.createScrollPane(null, true).apply {
horizontalScrollBarPolicy = ScrollPaneFactory.HORIZONTAL_SCROLLBAR_NEVER
}
val panel = JPanel(BorderLayout()).apply {
add(commitsPanel, BorderLayout.NORTH)
add(scrollPane, BorderLayout.CENTER)
}.also {
DataManager.registerDataProvider(it, object : EdtNoGetDataProvider {
override fun dataSnapshot(sink: DataSink) {
sink[CodeReviewChangeListViewModel.DATA_KEY] = changesVm.commitChangesVm.value.changeListVm.value.getOrNull()
}
})
}
val errorStatusPresenter = ErrorStatusPresenter.simple(message("pull.request.create.failed.to.load.changes"),
descriptionProvider = GHHtmlErrorPanel::getLoadingErrorText)
cs.launchNow {
changesVm.commitChangesVm.collectScoped { commitChangesVm ->
commitChangesVm.changeListVm.debounce(50).collectScoped { changeListVmResult ->
val content = changeListVmResult.fold({ moveToCenter(LoadingTextLabel()) },
{ createChangesPanel(it) },
{ moveToCenter(ErrorStatusPanelFactory.create(it, errorStatusPresenter)) })
scrollPane.setViewportView(content)
panel.revalidate()
panel.repaint()
}
}
}
return panel
}
private fun CoroutineScope.createChangesPanel(changeListVm: CodeReviewChangeListViewModel): JComponent =
CodeReviewChangeListComponentFactory.createIn(this, changeListVm, null,
message("pull.request.commit.does.not.contain.changes")).also {
it.installPopupHandler(ActionManager.getInstance().getAction("Github.PullRequest.Changes.Popup") as ActionGroup)
}
private fun CoroutineScope.metadataPanel(vm: GHPRCreateViewModel): JComponent {
val reviewersHandle = createReviewersListPanelHandle(vm.reviewersVm, vm.avatarIconsProvider)
val assigneesHandle = createAssigneesListPanelHandle(vm.assigneesVm, vm.avatarIconsProvider)
@@ -379,6 +432,13 @@ internal object GHPRCreateComponentFactory {
}
}
private fun createCommitsPopupPresenter(commit: VcsCommitMetadata) = CommitPresentation(
titleHtml = commit.subject,
descriptionHtml = commit.fullMessage.split("\n\n").getOrNull(1).orEmpty(),
author = VcsUserUtil.getShortPresentation(commit.author),
committedDate = Date(commit.authorTime)
)
private fun CoroutineScope.createReviewersListPanelHandle(vm: LabeledListPanelViewModel<GHPullRequestRequestedReviewer>,
avatarIconsProvider: GHAvatarIconsProvider) =
LabeledListPanelHandle(this, vm,
@@ -437,6 +497,11 @@ private fun ErrorLink(error: Throwable) =
}
}
@Suppress("FunctionName")
private fun HintPane(message: @Nls String) = SimpleHtmlPane(message).apply {
foreground = UIUtil.getContextHelpForeground()
}
private var Editor.margins
get() = (this as? EditorEx)?.scrollPane?.viewportBorder?.getBorderInsets(scrollPane.viewport) ?: JBUI.emptyInsets()
set(value) {

View File

@@ -0,0 +1,40 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.github.pullrequest.ui.toolwindow.create
import com.intellij.collaboration.ui.codereview.diff.CodeReviewDiffRequestProducer
import com.intellij.collaboration.ui.codereview.diff.model.CodeReviewDiffViewModelComputer
import com.intellij.collaboration.ui.codereview.diff.model.ComputedDiffViewModel
import com.intellij.collaboration.ui.codereview.diff.model.DiffProducersViewModel
import com.intellij.collaboration.ui.codereview.diff.model.RefComparisonChangesSorter
import com.intellij.collaboration.ui.codereview.diff.viewer.buildChangeContext
import com.intellij.collaboration.util.ChangesSelection
import com.intellij.collaboration.util.ComputedResult
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.openapi.vcs.changes.actions.diff.ChangeDiffRequestProducer
import com.intellij.platform.util.coroutines.childScope
import git4idea.changes.createVcsChange
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import org.jetbrains.plugins.github.pullrequest.config.GithubPullRequestsProjectUISettings
internal class GHPRCreateDiffViewModel(private val project: Project, parentCs: CoroutineScope) : ComputedDiffViewModel {
private val cs = parentCs.childScope(javaClass.name)
private val changesSorter = GithubPullRequestsProjectUISettings.getInstance(project).changesGroupingState
.map { RefComparisonChangesSorter.Grouping(project, it) }
private val helper =
CodeReviewDiffViewModelComputer(flowOf(ComputedResult.success(Unit)), changesSorter) { _, change ->
val changeContext: Map<Key<*>, Any> = change.buildChangeContext()
val changeDiffProducer = ChangeDiffRequestProducer.create(project, change.createVcsChange(project), changeContext)
?: error("Could not create diff producer from $change")
CodeReviewDiffRequestProducer(project, change, changeDiffProducer, null)
}
override val diffVm: StateFlow<ComputedResult<DiffProducersViewModel?>> =
helper.diffVm.stateIn(cs, SharingStarted.Eagerly, ComputedResult.loading())
fun showChanges(changes: ChangesSelection) {
helper.tryShowChanges(changes)
}
}

View File

@@ -2,12 +2,16 @@
package org.jetbrains.plugins.github.pullrequest.ui.toolwindow.create
import com.intellij.collaboration.async.*
import com.intellij.collaboration.ui.codereview.diff.CodeReviewDiffRequestProducer
import com.intellij.collaboration.ui.codereview.diff.model.ComputedDiffViewModel
import com.intellij.collaboration.util.CollectionDelta
import com.intellij.collaboration.util.ComputedResult
import com.intellij.collaboration.util.computeEmitting
import com.intellij.collaboration.util.getOrNull
import com.intellij.dvcs.DvcsUtil
import com.intellij.dvcs.push.PushSpec
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.progress.coroutineToIndicator
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
@@ -55,7 +59,8 @@ internal interface GHPRCreateViewModel {
val repositories: Collection<GHGitRepositoryMapping>
val branches: StateFlow<BranchesState>
val commits: StateFlow<ComputedResult<List<VcsCommitMetadata>>?>
val changesVm: StateFlow<ComputedResult<GHPRCreateChangesViewModel?>?>
val diffVm: ComputedDiffViewModel
val titleText: StateFlow<String>
val descriptionText: StateFlow<String>
@@ -101,6 +106,7 @@ internal interface GHPRCreateViewModel {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
internal class GHPRCreateViewModelImpl(override val project: Project,
parentCs: CoroutineScope,
private val repositoryManager: GHHostedRepositoriesManager,
@@ -123,9 +129,33 @@ internal class GHPRCreateViewModelImpl(override val project: Project,
override val branches: StateFlow<BranchesState> = branchesWithProgress.map { it.branches }
.stateInNow(cs, branchesWithProgress.value.branches)
override val commits: StateFlow<ComputedResult<List<VcsCommitMetadata>>?> =
branchesWithProgress.map { it.commitsRequest }.optionalComputationState()
.stateIn(cs, SharingStarted.Lazily, ComputedResult.loading())
@OptIn(ExperimentalCoroutinesApi::class)
override val changesVm: StateFlow<ComputedResult<GHPRCreateChangesViewModel?>?> = branchesWithProgress.transformLatest { branches ->
val commits = try {
branches.commitsRequest?.await()
}
catch (ce: CancellationException) {
currentCoroutineContext().ensureActive()
null
}
catch (e: Exception) {
emit(ComputedResult.failure(e))
return@transformLatest
}
if (commits == null) {
emit(null)
}
else if (commits.isEmpty()) {
emit(ComputedResult.success(null))
}
else coroutineScope {
val settings = project.serviceAsync<GithubPullRequestsProjectUISettings>()
val vm = GHPRCreateChangesViewModel(project, settings, this, dataContext,
branches.branches.baseBranch!!, branches.branches.headBranch!!, commits)
emit(ComputedResult.success(vm))
}
}.stateInNow(cs, null)
override val diffVm = GHPRCreateDiffViewModel(project, cs)
override val titleText: MutableStateFlow<String> = MutableStateFlow("")
override val descriptionText: MutableStateFlow<String> = MutableStateFlow("")
@@ -177,17 +207,41 @@ internal class GHPRCreateViewModelImpl(override val project: Project,
setTitleFromFirstCommitOrBranch(headBranch, commits)
}
}
cs.launchNow {
changesVm.flatMapLatest {
it?.getOrNull()?.commitChangesVm ?: flowOf(null)
}.flatMapLatest {
it?.changeListVm ?: flowOf(null)
}.map {
it?.getOrNull()
}.collectScoped { vm ->
vm?.handleSelection {
if (it != null) {
diffVm.showChanges(it)
}
}
}
}
cs.launchNow {
diffVm.diffVm.collectScoped {
it.getOrNull()?.handleSelection { producer ->
val change = (producer as? CodeReviewDiffRequestProducer)?.change ?: return@handleSelection
val changesVm = changesVm.value?.getOrNull()
//TODO: handle different commit
val commitChangesVm = changesVm?.commitChangesVm?.value
val changeListVm = commitChangesVm?.changeListVm?.value?.getOrNull()
changeListVm?.selectChange(change)
}
}
}
}
private fun setTitleFromFirstCommitOrBranch(headBranch: GitBranch, commits: List<VcsCommitMetadata>) {
titleLock.withLock {
val singleCommit = commits.singleOrNull()
titleText.value = if (singleCommit != null) {
singleCommit.fullMessage.split("\n\n").firstOrNull().orEmpty()
}
else {
headBranch.name
}
titleText.value = singleCommit?.subject ?: headBranch.name
}
}
@@ -443,14 +497,4 @@ private class MetadataListViewModel<T>(cs: CoroutineScope, itemsLoader: suspend
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun <T> Flow<Deferred<T>?>.optionalComputationState(): Flow<ComputedResult<T>?> =
transformLatest { request ->
if (request == null) {
emit(null)
return@transformLatest
}
flowOf(request).computationState().collect(this)
}

View File

@@ -39,6 +39,7 @@ import org.jetbrains.plugins.github.pullrequest.ui.editor.GHPRReviewInEditorView
import org.jetbrains.plugins.github.pullrequest.ui.review.GHPRBranchWidgetViewModel
import org.jetbrains.plugins.github.pullrequest.ui.timeline.GHPRTimelineViewModel
import org.jetbrains.plugins.github.pullrequest.ui.toolwindow.GHPRToolWindowTab
import org.jetbrains.plugins.github.pullrequest.ui.toolwindow.create.GHPRCreateViewModel
import org.jetbrains.plugins.github.pullrequest.ui.toolwindow.create.GHPRCreateViewModelImpl
import org.jetbrains.plugins.github.ui.util.GHUIUtil
import org.jetbrains.plugins.github.util.DisposalCountingHolder
@@ -54,7 +55,7 @@ class GHPRToolWindowProjectViewModel internal constructor(
private val twVm: GHPRToolWindowViewModel,
connection: GHRepositoryConnection
) : ReviewToolwindowProjectViewModel<GHPRToolWindowTab, GHPRToolWindowTabViewModel> {
private val cs = parentCs.childScope()
private val cs = parentCs.childScope(javaClass.name)
internal val dataContext: GHPRDataContext = connection.dataContext
@@ -68,6 +69,7 @@ class GHPRToolWindowProjectViewModel internal constructor(
private val lazyCreateVm = SynchronizedClearableLazy {
GHPRCreateViewModelImpl(project, cs, repoManager, GithubPullRequestsProjectUISettings.getInstance(project), connection.dataContext, this)
}
internal fun getCreateVmOrNull(): GHPRCreateViewModel? = lazyCreateVm.valueIfInitialized
private val pullRequestsVms = Caffeine.newBuilder().build<GHPRIdentifier, DisposalCountingHolder<GHPRViewModelContainer>> { id ->
DisposalCountingHolder {
@@ -89,6 +91,9 @@ class GHPRToolWindowProjectViewModel internal constructor(
tabsHelper.close(tab)
if (reset) {
lazyCreateVm.drop()?.let(Disposer::dispose)
cs.launch {
dataContext.filesManager.closeNewPrFile()
}
}
}
}