diff --git a/platform/collaboration-tools/api-dump-experimental.txt b/platform/collaboration-tools/api-dump-experimental.txt index df6fc11ee43d..376f77a3c315 100644 --- a/platform/collaboration-tools/api-dump-experimental.txt +++ b/platform/collaboration-tools/api-dump-experimental.txt @@ -1566,6 +1566,7 @@ f:com.intellij.collaboration.ui.codereview.diff.viewer.DiffViewerUtilKt - sf:bindSelectedIn(javax.swing.JCheckBox,kotlinx.coroutines.CoroutineScope,kotlinx.coroutines.flow.MutableStateFlow):V - sf:bindSelectedItemIn(com.intellij.ui.dsl.builder.Cell,kotlinx.coroutines.CoroutineScope,kotlinx.coroutines.flow.MutableStateFlow):com.intellij.ui.dsl.builder.Cell - sf:bindSelectedItemIn(javax.swing.ComboBoxModel,kotlinx.coroutines.CoroutineScope,kotlinx.coroutines.flow.MutableStateFlow):V +- sf:bindText(javax.swing.JLabel,java.lang.String,kotlinx.coroutines.flow.Flow):V - sf:bindTextHtmlIn(javax.swing.JEditorPane,kotlinx.coroutines.CoroutineScope,kotlinx.coroutines.flow.Flow):V - sf:bindTextIn(com.intellij.openapi.editor.Document,kotlinx.coroutines.CoroutineScope,kotlinx.coroutines.flow.MutableStateFlow):V - sf:bindTextIn(com.intellij.openapi.editor.Document,kotlinx.coroutines.CoroutineScope,kotlinx.coroutines.flow.StateFlow,kotlin.jvm.functions.Function1):V diff --git a/platform/collaboration-tools/src/com/intellij/collaboration/ui/util/swingBindings.kt b/platform/collaboration-tools/src/com/intellij/collaboration/ui/util/swingBindings.kt index 5d101fec64c8..da62c88cd8e2 100644 --- a/platform/collaboration-tools/src/com/intellij/collaboration/ui/util/swingBindings.kt +++ b/platform/collaboration-tools/src/com/intellij/collaboration/ui/util/swingBindings.kt @@ -240,6 +240,12 @@ fun JEditorPane.bindTextHtmlIn(scope: CoroutineScope, textFlow: Flow<@Nls String } } +fun JLabel.bindText(debugName: String, textFlow: Flow<@Nls String>) { + launchOnShow(debugName) { + bindTextIn(this, textFlow) + } +} + fun JLabel.bindTextIn(scope: CoroutineScope, textFlow: Flow<@Nls String>) { scope.launch(start = CoroutineStart.UNDISPATCHED) { textFlow.collect { diff --git a/plugins/github/github-core/resources/graphql/fragment/pullRequestInfo.graphql b/plugins/github/github-core/resources/graphql/fragment/pullRequestInfo.graphql index 8fdb4d8c5d9d..2fbd5aed804b 100644 --- a/plugins/github/github-core/resources/graphql/fragment/pullRequestInfo.graphql +++ b/plugins/github/github-core/resources/graphql/fragment/pullRequestInfo.graphql @@ -16,9 +16,14 @@ fragment pullRequestInfo on PullRequest { ...repository } + headRef { + id + } headRefName headRefOid headRepository { ...repository } + + viewerCanDeleteHeadRef } \ No newline at end of file diff --git a/plugins/github/github-core/resources/graphql/query/deleteRef.graphql b/plugins/github/github-core/resources/graphql/query/deleteRef.graphql new file mode 100644 index 000000000000..e691c6ae9959 --- /dev/null +++ b/plugins/github/github-core/resources/graphql/query/deleteRef.graphql @@ -0,0 +1,5 @@ +mutation ($refId: ID!) { + deleteRef(input: {refId: $refId}) { + clientMutationId + } +} \ No newline at end of file diff --git a/plugins/github/github-core/resources/intellij.vcs.github.xml b/plugins/github/github-core/resources/intellij.vcs.github.xml index 955bcbcb24ff..85de7bf8bb9c 100644 --- a/plugins/github/github-core/resources/intellij.vcs.github.xml +++ b/plugins/github/github-core/resources/intellij.vcs.github.xml @@ -125,6 +125,10 @@ + + diff --git a/plugins/github/github-core/resources/messages/GithubBundle.properties b/plugins/github/github-core/resources/messages/GithubBundle.properties index d0d0981f63fd..ff0c2175f544 100644 --- a/plugins/github/github-core/resources/messages/GithubBundle.properties +++ b/plugins/github/github-core/resources/messages/GithubBundle.properties @@ -323,6 +323,16 @@ pull.request.timeline.comment.suggested.changes.tooltip.different.branch=Unable pull.request.comment.suggested.changes.applying=Applying patch pull.request.comment.suggested.changes.resolving=Resolving thread pull.request.comment.suggested.changes.committing=Applying and committing changes + +pullRequest.timeline.merged.title=Pull Request Successfully Merged +pullRequest.timeline.merged.body.restore=Restore\u2026 +pullRequest.timeline.merged.body.delete=delete +pullRequest.timeline.merged.body.withoutDelete={0} has been deleted. {1} +pullRequest.timeline.merged.body.withDelete=You can now {0} {1}. + +action.GitHub.Timeline.DeleteMergedBranch.text=Delete Branch +action.GitHub.Timeline.DeleteMergedBranch.description=Delete the merged branch on GitHub + #branch widget pull.request.on.branch=\#{0,number,#} on {1} pull.request.on.branch.description=Pull request \#{0,number,#} exists for current branch {1} diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/api/GHGQLQueries.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/api/GHGQLQueries.kt index f66034aba768..d5398d43210a 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/api/GHGQLQueries.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/api/GHGQLQueries.kt @@ -21,6 +21,7 @@ object GHGQLQueries { const val pullRequestCommits = "graphql/query/pullRequestCommits.graphql" const val pullRequestMergeabilityData = "graphql/query/findPullRequestMergeability.graphql" const val pullRequestFiles = "graphql/query/pullRequestFiles.graphql" + const val deleteRef = "graphql/query/deleteRef.graphql" const val markFileAsViewed = "graphql/query/markFileAsViewed.graphql" const val unmarkFileAsViewed = "graphql/query/unmarkFileAsViewed.graphql" const val createReview = "graphql/query/createReview.graphql" diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/api/GHGQLRequests.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/api/GHGQLRequests.kt index 9e2b6dfde5ef..b33339e8d362 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/api/GHGQLRequests.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/api/GHGQLRequests.kt @@ -195,6 +195,15 @@ object GHGQLRequests { } } + object Ref { + fun delete(server: GithubServerPath, refId: String): GQLQuery = + GQLQuery.Parsed( + server.toGraphQLUrl(), GHGQLQueries.deleteRef, + mapOf("refId" to refId), + Unit::class.java + ) + } + object PullRequest { fun findOneId(repository: GHRepositoryCoordinates, number: Long): GQLQuery = GQLQuery.OptionalTraversedParsed( diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/api/data/pullrequest/GHPullRequest.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/api/data/pullrequest/GHPullRequest.kt index cf918e663b63..03778c18a6a4 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/api/data/pullrequest/GHPullRequest.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/api/data/pullrequest/GHPullRequest.kt @@ -9,47 +9,56 @@ import org.jetbrains.plugins.github.api.data.* import java.util.* @GraphQLFragment("/graphql/fragment/pullRequestInfo.graphql") -class GHPullRequest(id: String, - url: String, - number: Long, - title: String, - state: GHPullRequestState, - isDraft: Boolean, - author: GHActor?, - createdAt: Date, - updatedAt: Date, - @JsonProperty("assignees") assignees: GraphQLNodesDTO, - @JsonProperty("labels") labels: GraphQLNodesDTO?, - @JsonProperty("reviewRequests") reviewRequests: GraphQLNodesDTO?, - @JsonProperty("reviewThreads") reviewThreads: GraphQLNodesDTO, - @JsonProperty("reviews") reviews: GraphQLNodesDTO?, - val reviewDecision: GHPullRequestReviewDecision?, - mergeable: GHPullRequestMergeableState, - viewerCanUpdate: Boolean, - viewerCanReact: Boolean, - viewerDidAuthor: Boolean, - @NlsSafe val body: String, - val baseRefName: String, - val baseRefOid: String, - val baseRepository: GHRepository?, - val headRefName: String, - val headRefOid: String, - val headRepository: GHRepository?, - override val reactions: GHReactable.ReactionConnection) +class GHPullRequest( + id: String, + url: String, + number: Long, + title: String, + state: GHPullRequestState, + isDraft: Boolean, + author: GHActor?, + createdAt: Date, + updatedAt: Date, + @JsonProperty("assignees") assignees: GraphQLNodesDTO, + @JsonProperty("labels") labels: GraphQLNodesDTO?, + @JsonProperty("reviewRequests") reviewRequests: GraphQLNodesDTO?, + @JsonProperty("reviewThreads") reviewThreads: GraphQLNodesDTO, + @JsonProperty("reviews") reviews: GraphQLNodesDTO?, + val reviewDecision: GHPullRequestReviewDecision?, + mergeable: GHPullRequestMergeableState, + viewerCanUpdate: Boolean, + viewerCanReact: Boolean, + viewerDidAuthor: Boolean, + val viewerCanDeleteHeadRef: Boolean, + val body: @NlsSafe String, + val baseRefName: String, + val baseRefOid: String, + val baseRepository: GHRepository?, + private val headRef: HeadRef?, + val headRefName: String, + val headRefOid: String, + val headRepository: GHRepository?, + override val reactions: GHReactable.ReactionConnection, +) : GHPullRequestShort(id, url, number, title, state, isDraft, author, createdAt, updatedAt, assignees, labels, reviewRequests, reviewThreads, reviews, mergeable, viewerCanUpdate, viewerCanReact, viewerDidAuthor, reactions) { + val headRefId: String? get() = headRef?.id + + data class HeadRef(val id: String?) override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is GHPullRequest) return false if (!super.equals(other)) return false + if (viewerCanDeleteHeadRef != other.viewerCanDeleteHeadRef) return false if (reviewDecision != other.reviewDecision) return false if (body != other.body) return false if (baseRefName != other.baseRefName) return false if (baseRefOid != other.baseRefOid) return false if (baseRepository != other.baseRepository) return false + if (headRef != other.headRef) return false if (headRefName != other.headRefName) return false if (headRefOid != other.headRefOid) return false if (headRepository != other.headRepository) return false @@ -60,11 +69,13 @@ class GHPullRequest(id: String, override fun hashCode(): Int { var result = super.hashCode() + result = 31 * result + viewerCanDeleteHeadRef.hashCode() result = 31 * result + (reviewDecision?.hashCode() ?: 0) result = 31 * result + body.hashCode() result = 31 * result + baseRefName.hashCode() result = 31 * result + baseRefOid.hashCode() result = 31 * result + (baseRepository?.hashCode() ?: 0) + result = 31 * result + headRef.hashCode() result = 31 * result + headRefName.hashCode() result = 31 * result + headRefOid.hashCode() result = 31 * result + (headRepository?.hashCode() ?: 0) diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/action/GHPRDeleteMergedHeadAction.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/action/GHPRDeleteMergedHeadAction.kt new file mode 100644 index 000000000000..740eb6058e3d --- /dev/null +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/action/GHPRDeleteMergedHeadAction.kt @@ -0,0 +1,23 @@ +// 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.github.pullrequest.action + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import org.jetbrains.plugins.github.pullrequest.ui.timeline.GHPRTimelineViewModel + +internal class GHPRDeleteMergedHeadAction : DumbAwareAction() { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = false + + val vm = e.getData(GHPRTimelineViewModel.DATA_KEY) ?: return + e.presentation.isEnabledAndVisible = vm.detailsVm.canDeleteMergedBranch.value + } + + override fun actionPerformed(e: AnActionEvent) { + val vm = e.getData(GHPRTimelineViewModel.DATA_KEY) ?: return + vm.detailsVm.deleteMergedBranch() + } +} \ No newline at end of file diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/provider/GHPRDetailsDataProvider.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/provider/GHPRDetailsDataProvider.kt index b5668226b743..307deda95f12 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/provider/GHPRDetailsDataProvider.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/provider/GHPRDetailsDataProvider.kt @@ -42,6 +42,8 @@ interface GHPRDetailsDataProvider { suspend fun squashMerge(commitMessage: Pair, currentHeadRef: String) + suspend fun deleteMergedBranch(refId: String) + suspend fun signalDetailsNeedReload() suspend fun signalMergeabilityNeedsReload() diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/provider/GHPRDetailsDataProviderImpl.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/provider/GHPRDetailsDataProviderImpl.kt index ac23b13a6f74..5dc10967e893 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/provider/GHPRDetailsDataProviderImpl.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/provider/GHPRDetailsDataProviderImpl.kt @@ -113,6 +113,15 @@ internal class GHPRDetailsDataProviderImpl(parentCs: CoroutineScope, notifyNeedsReload() } + override suspend fun deleteMergedBranch(refId: String) { + try { + detailsService.deleteMergedBranch(pullRequestId, refId) + } + finally { + notifyNeedsReload() + } + } + private suspend fun notifyNeedsReload(state: Boolean = true) { withContext(NonCancellable) { signalDetailsNeedReload() diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/service/GHPRDetailsService.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/service/GHPRDetailsService.kt index 2d6c1d10d60b..080ce978ab5e 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/service/GHPRDetailsService.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/service/GHPRDetailsService.kt @@ -35,4 +35,6 @@ internal interface GHPRDetailsService { suspend fun rebaseMerge(pullRequestId: GHPRIdentifier, currentHeadRef: String) suspend fun squashMerge(pullRequestId: GHPRIdentifier, commitMessage: Pair, currentHeadRef: String) + + suspend fun deleteMergedBranch(pullRequestId: GHPRIdentifier, refId: String) } \ No newline at end of file diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/service/GHPRDetailsServiceImpl.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/service/GHPRDetailsServiceImpl.kt index 89f3315415b2..d7dbb5446367 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/service/GHPRDetailsServiceImpl.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/service/GHPRDetailsServiceImpl.kt @@ -180,6 +180,16 @@ internal class GHPRDetailsServiceImpl( } } + override suspend fun deleteMergedBranch(pullRequestId: GHPRIdentifier, refId: String) { + runCatching { + requestExecutor.executeSuspend( + GHGQLRequests.Ref.delete(repository.serverPath, refId) + ) + }.processErrorAndGet { e -> + LOG.info("Error occurred while deleting branch for PR ${pullRequestId.number}", e) + } + } + companion object { private val LOG = logger() } diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/details/model/GHPRDetailsFull.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/details/model/GHPRDetailsFull.kt index e644c03030a2..518f6cfbc2a8 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/details/model/GHPRDetailsFull.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/details/model/GHPRDetailsFull.kt @@ -4,16 +4,23 @@ package org.jetbrains.plugins.github.pullrequest.ui.details.model import com.intellij.openapi.util.NlsSafe import org.jetbrains.plugins.github.api.data.GHActor import org.jetbrains.plugins.github.api.data.GHReaction +import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequestState import org.jetbrains.plugins.github.pullrequest.data.GHPRIdentifier import java.util.* -data class GHPRDetailsFull(val id: GHPRIdentifier, - val url: String, - val author: GHActor, - val createdAt: Date, - val titleHtml: @NlsSafe String, - val description: String?, - val descriptionHtml: @NlsSafe String?, - val canEditDescription: Boolean, - val canReactDescription: Boolean, - val reactions: List) +data class GHPRDetailsFull( + val id: GHPRIdentifier, + val url: String, + val author: GHActor, + val createdAt: Date, + val state: GHPullRequestState, + val titleHtml: @NlsSafe String, + val description: String?, + val descriptionHtml: @NlsSafe String?, + val headRefId: String?, + val headRefName: String?, + val canEditDescription: Boolean, + val canReactDescription: Boolean, + val canDeleteHeadRef: Boolean, + val reactions: List, +) diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRDetailsTimelineViewModel.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRDetailsTimelineViewModel.kt index e9d29f53a2ec..771729e06d8b 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRDetailsTimelineViewModel.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRDetailsTimelineViewModel.kt @@ -2,20 +2,25 @@ package org.jetbrains.plugins.github.pullrequest.ui.timeline import com.intellij.collaboration.async.childScope +import com.intellij.collaboration.async.mapState import com.intellij.collaboration.async.stateInNow import com.intellij.collaboration.ui.codereview.comment.CodeReviewSubmittableTextViewModelBase import com.intellij.collaboration.ui.codereview.comment.CodeReviewTextEditingViewModel import com.intellij.collaboration.ui.icon.IconsProvider import com.intellij.collaboration.util.ComputedResult +import com.intellij.collaboration.util.ResultUtil.runCatchingUser import com.intellij.collaboration.util.getOrNull import com.intellij.collaboration.util.map +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import org.jetbrains.plugins.github.api.data.GHReactionContent import org.jetbrains.plugins.github.api.data.GHUser import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequest +import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequestState import org.jetbrains.plugins.github.pullrequest.comment.convertToHtml import org.jetbrains.plugins.github.pullrequest.data.GHPRDataContext import org.jetbrains.plugins.github.pullrequest.data.GHReactionsService @@ -26,10 +31,16 @@ import org.jetbrains.plugins.github.pullrequest.ui.details.model.GHPRDetailsFull import org.jetbrains.plugins.github.pullrequest.ui.emoji.GHReactionViewModelImpl import org.jetbrains.plugins.github.pullrequest.ui.emoji.GHReactionsViewModel -class GHPRDetailsTimelineViewModel internal constructor(private val project: Project, - parentCs: CoroutineScope, - private val dataContext: GHPRDataContext, - private val dataProvider: GHPRDataProvider) { +class GHPRDetailsTimelineViewModel internal constructor( + private val project: Project, + parentCs: CoroutineScope, + private val dataContext: GHPRDataContext, + private val dataProvider: GHPRDataProvider, +) { + companion object { + private val LOG = logger() + } + private val cs = parentCs.childScope(this::class) private val currentUser: GHUser = dataContext.securityService.currentUser @@ -47,16 +58,31 @@ class GHPRDetailsTimelineViewModel internal constructor(private val project: Pro val reactionsVm: GHReactionsViewModel = GHReactionViewModelImpl(cs, dataProvider.id.id, loadedReactionsState, currentUser, reactionsService, reactionIconsProvider) + val isMerged: StateFlow = + details.mapState { it.getOrNull()?.state == GHPullRequestState.MERGED } + val canDeleteMergedBranch: StateFlow = + details.mapState { + val details = it.getOrNull() ?: return@mapState false + details.canDeleteHeadRef && details.state == GHPullRequestState.MERGED + } + + val headRefName: StateFlow = + details.mapState { it.getOrNull()?.headRefName } + private fun createDetails(data: GHPullRequest): GHPRDetailsFull = GHPRDetailsFull( dataProvider.id, data.url, data.author ?: dataContext.securityService.ghostUser, data.createdAt, + data.state, data.title.convertToHtml(project), data.body, data.body.convertToHtml(project), + data.headRefId, + data.headRefName, data.viewerCanUpdate, data.viewerCanReact, + data.viewerCanDeleteHeadRef && data.headRefId != null, data.reactions.nodes ) @@ -73,14 +99,29 @@ class GHPRDetailsTimelineViewModel internal constructor(private val project: Pro requestFocus() } } + + fun deleteMergedBranch() { + if (!canDeleteMergedBranch.value) return + val details = details.value.getOrNull() ?: return + val refId = details.headRefId ?: return + + cs.launch { + runCatchingUser { + dataProvider.detailsData.deleteMergedBranch(refId) + }.onFailure { e -> + LOG.warn(e) + } + } + } } -class GHPREditDescriptionViewModel internal constructor(project: Project, - parentCs: CoroutineScope, - private val detailsData: GHPRDetailsDataProvider, - initialText: String, - private val onDone: () -> Unit) - : CodeReviewSubmittableTextViewModelBase(project, parentCs, initialText), CodeReviewTextEditingViewModel { +class GHPREditDescriptionViewModel internal constructor( + project: Project, + parentCs: CoroutineScope, + private val detailsData: GHPRDetailsDataProvider, + initialText: String, + private val onDone: () -> Unit, +) : CodeReviewSubmittableTextViewModelBase(project, parentCs, initialText), CodeReviewTextEditingViewModel { override fun save() { submit { detailsData.updateDetails(description = it) diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRFileEditorComponentFactory.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRFileEditorComponentFactory.kt index 466505ce69ad..0a767be292ac 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRFileEditorComponentFactory.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRFileEditorComponentFactory.kt @@ -13,11 +13,10 @@ import com.intellij.collaboration.ui.codereview.comment.submitActionIn import com.intellij.collaboration.ui.codereview.list.error.ErrorStatusPanelFactory import com.intellij.collaboration.ui.codereview.list.error.ErrorStatusPresenter import com.intellij.collaboration.ui.codereview.timeline.comment.CommentTextFieldFactory -import com.intellij.collaboration.ui.util.bindContent -import com.intellij.collaboration.ui.util.bindTextHtmlIn -import com.intellij.collaboration.ui.util.bindTextIn -import com.intellij.collaboration.ui.util.bindVisibilityIn +import com.intellij.collaboration.ui.util.* import com.intellij.collaboration.util.getOrNull +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil import com.intellij.ide.DataManager import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces @@ -31,6 +30,7 @@ import com.intellij.ui.PopupHandler import com.intellij.ui.ScrollPaneFactory import com.intellij.ui.components.panels.ListLayout import com.intellij.ui.components.panels.Wrapper +import com.intellij.util.IconUtil import com.intellij.util.asDisposable import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI @@ -38,6 +38,7 @@ import com.intellij.util.ui.UIUtil import com.intellij.util.ui.update.UiNotifyConnector import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* +import org.jetbrains.plugins.github.GithubIcons import org.jetbrains.plugins.github.ai.GHPRAISummaryExtension import org.jetbrains.plugins.github.exceptions.GithubAuthenticationException import org.jetbrains.plugins.github.i18n.GithubBundle @@ -45,13 +46,16 @@ import org.jetbrains.plugins.github.pullrequest.ui.GHPRConnectedProjectViewModel import org.jetbrains.plugins.github.pullrequest.ui.details.model.GHPRDetailsFull import org.jetbrains.plugins.github.pullrequest.ui.emoji.GHReactionsComponentFactory import org.jetbrains.plugins.github.pullrequest.ui.emoji.GHReactionsPickerComponentFactory +import org.jetbrains.plugins.github.pullrequest.ui.timeline.GHPRTimelineEventComponentFactoryImpl.Companion.branchHTML import org.jetbrains.plugins.github.ui.component.GHHtmlErrorPanel import org.jetbrains.plugins.github.ui.util.addGithubHyperlinkListener +import java.awt.Font import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel import javax.swing.event.ChangeEvent import javax.swing.event.ChangeListener +import javax.swing.event.HyperlinkEvent internal class GHPRFileEditorComponentFactory( private val cs: CoroutineScope, @@ -69,7 +73,15 @@ internal class GHPRFileEditorComponentFactory( val description = createDescription(loadedDetails) val itemComponentFactory = createItemComponentFactory() - val timeline = ComponentListPanelFactory.createVertical(cs, timelineVm.timelineItems, componentFactory = itemComponentFactory) + val timelineEvents = ComponentListPanelFactory.createVertical(cs, timelineVm.timelineItems, componentFactory = itemComponentFactory) + val mergedTimelineItem = Wrapper().apply { + bindContent( + debugName = "mergeActionChild", + dataFlow = combine(timelineVm.detailsVm.isMerged, timelineVm.loadingError, ::Pair).distinctUntilChanged() + ) { (isMerged, loadingError) -> + if (isMerged && loadingError == null) createMergedTimelineItem(timelineVm.detailsVm) else null + } + } val progressAndErrorPanel = JPanel(ListLayout.vertical(0, ListLayout.Alignment.CENTER)).apply { isOpaque = false @@ -112,9 +124,10 @@ internal class GHPRFileEditorComponentFactory( }) add(description) - add(timeline) + add(timelineEvents) add(progressAndErrorPanel) + add(mergedTimelineItem) timelineVm.commentVm?.also { val commentTextField = createCommentField(it).apply { @@ -138,8 +151,7 @@ internal class GHPRFileEditorComponentFactory( } }) } - UiNotifyConnector.doWhenFirstShown(scrollPane) - { + UiNotifyConnector.doWhenFirstShown(scrollPane) { timelineVm.requestMore() } @@ -226,6 +238,57 @@ internal class GHPRFileEditorComponentFactory( return CodeReviewCommentTextFieldFactory.createIn(cs, vm, actions, icon) } + private fun createMergedTimelineItem(detailsVm: GHPRDetailsTimelineViewModel): JComponent { + return CodeReviewChatItemUIUtil.build( + type = CodeReviewChatItemUIUtil.ComponentType.FULL, + iconProvider = { iconSize -> IconUtil.resizeSquared(GithubIcons.PullRequestMerged, iconSize) }, + content = HorizontalListPanel(gap = 4).apply { + add(SimpleHtmlPane(addBrowserListener = false).apply { + addHyperlinkListener { e -> + if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener + + if (e.description == DELETE_ACTION_NAME) { + detailsVm.deleteMergedBranch() + } + else if (e.description == RESTORE_ACTION_NAME) { + val url = detailsVm.details.value.getOrNull()?.url ?: return@addHyperlinkListener + BrowserUtil.browse(url) + } + } + + bindTextHtml( + "mergedTimelineItem.body.text", + combine(detailsVm.canDeleteMergedBranch, detailsVm.headRefName) { canDeleteMergedBranch, headRefName -> + val branchHtml = headRefName?.let(::branchHTML) ?: "branch" + if (canDeleteMergedBranch) { + val deleteBranchHtml = GithubBundle.message("pullRequest.timeline.merged.body.delete") + val link = HtmlChunk.link(DELETE_ACTION_NAME, HtmlChunk.raw(deleteBranchHtml)) + + GithubBundle.message("pullRequest.timeline.merged.body.withDelete", link, branchHtml) + } + else { + val restoreText = GithubBundle.message("pullRequest.timeline.merged.body.restore") + val link = HtmlChunk.link(RESTORE_ACTION_NAME, HtmlChunk.raw(restoreText)) + + GithubBundle.message("pullRequest.timeline.merged.body.withoutDelete", branchHtml, link) + } + } + ) + }) + + add(JLabel().apply { + bindIcon("mergedTimelineItem.body.icon", detailsVm.canDeleteMergedBranch.map { canDeleteMergedBranch -> + if (!canDeleteMergedBranch) AllIcons.Ide.External_link_arrow else null + }) + }) + } + ) { + withHeader(JLabel(GithubBundle.message("pullRequest.timeline.merged.title")).apply { + font = font.deriveFont(Font.BOLD) + }) + } + } + private fun createItemComponentFactory() = GHPRTimelineItemComponentFactory(project, timelineVm) private val noDescriptionHtmlText by lazy { @@ -235,4 +298,9 @@ internal class GHPRFileEditorComponentFactory( .wrapWith("i") .toString() } + + companion object { + private const val RESTORE_ACTION_NAME = "restore" + private const val DELETE_ACTION_NAME = "delete" + } } \ No newline at end of file diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRTimelineEventComponentFactoryImpl.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRTimelineEventComponentFactoryImpl.kt index 6b433697bc38..0a9d21a85f29 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRTimelineEventComponentFactoryImpl.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRTimelineEventComponentFactoryImpl.kt @@ -248,7 +248,7 @@ internal class GHPRTimelineEventComponentFactoryImpl( } companion object { - private fun branchHTML(name: @Nls String): HtmlChunk { + internal fun branchHTML(name: @Nls String): HtmlChunk { val foreground = CurrentBranchComponent.TEXT_COLOR val background = CurrentBranchComponent.getBranchPresentationBackground(UIUtil.getListBackground()) diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRTimelineViewModel.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRTimelineViewModel.kt index 1f4bd3b7d786..d5230e9f0154 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRTimelineViewModel.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRTimelineViewModel.kt @@ -91,7 +91,7 @@ internal class GHPRTimelineViewModelImpl( override val ghostUser: GHUser = securityService.ghostUser override val currentUser: GHUser = securityService.currentUser - override val detailsVm = GHPRDetailsTimelineViewModel(project, parentCs, dataContext, dataProvider) + override val detailsVm = GHPRDetailsTimelineViewModel(project, cs, dataContext, dataProvider) private val timelineLoader = dataProvider.acquireTimelineLoader(cs) override val loadingErrorHandler: GHLoadingErrorHandler = @@ -99,7 +99,7 @@ internal class GHPRTimelineViewModelImpl( override val commentVm: GHPRNewCommentViewModel? = if (securityService.currentUserHasPermissionLevel(GHRepositoryPermissionLevel.READ)) { - GHPRNewCommentViewModel(project, parentCs, commentsData) + GHPRNewCommentViewModel(project, cs, commentsData) } else null diff --git a/plugins/github/github-core/test/org/jetbrains/plugins/github/api/GHGQLDataTest.kt b/plugins/github/github-core/test/org/jetbrains/plugins/github/api/GHGQLDataTest.kt index b90c8622fd24..f5921cc586c3 100644 --- a/plugins/github/github-core/test/org/jetbrains/plugins/github/api/GHGQLDataTest.kt +++ b/plugins/github/github-core/test/org/jetbrains/plugins/github/api/GHGQLDataTest.kt @@ -32,7 +32,6 @@ import org.jetbrains.plugins.github.api.data.GHReactionContent import org.junit.Ignore import org.junit.Test import org.junit.jupiter.api.assertDoesNotThrow -import org.junit.jupiter.api.assertThrows import org.junit.runner.RunWith import org.junit.runners.Parameterized import java.util.* @@ -526,6 +525,8 @@ private object TestCases { GHGQLRequests.PullRequest.markFileAsViewed(DUMMY_SERVER_PATH, DUMMY_TEXT, DUMMY_TEXT), GHGQLRequests.PullRequest.unmarkFileAsViewed(DUMMY_SERVER_PATH, DUMMY_TEXT, DUMMY_TEXT), + GHGQLRequests.Ref.delete(DUMMY_SERVER_PATH, DUMMY_TEXT), + GHGQLRequests.PullRequest.Timeline.items(DUMMY_SERVER_PATH, DUMMY_TEXT, DUMMY_TEXT, DUMMY_NUMBER, DUMMY_PAGINATION), GHGQLRequests.PullRequest.Timeline.items(DUMMY_SERVER_PATH, DUMMY_TEXT, DUMMY_TEXT, DUMMY_NUMBER, null), GHGQLRequests.PullRequest.Review.create(DUMMY_SERVER_PATH, DUMMY_TEXT, DUMMY_PR_EVENT, DUMMY_TEXT, DUMMY_TEXT, listOf()), @@ -586,8 +587,8 @@ class GHGQLDeserializationAssumptionsTest { ) @Test - fun `deserializing a missing value instead of a list is not fine`() { - assertThrows { mapper.readValue("""{}""", ListHolder::class.java) } + fun `deserializing a missing value instead of a list is fine`() { + assertDoesNotThrow { mapper.readValue("""{}""", ListHolder::class.java) } } @Test