diff --git a/platform/platform-api/src/com/intellij/util/io/HttpRequests.java b/platform/platform-api/src/com/intellij/util/io/HttpRequests.java index bb8051d2bac9..e0beb322fba6 100644 --- a/platform/platform-api/src/com/intellij/util/io/HttpRequests.java +++ b/platform/platform-api/src/com/intellij/util/io/HttpRequests.java @@ -173,6 +173,11 @@ public final class HttpRequests { return new RequestBuilderImpl(url, connection -> ((HttpURLConnection)connection).setRequestMethod("DELETE")); } + @NotNull + public static RequestBuilder delete(@NotNull String url, @Nullable String contentType) { + return requestWithBody(url, "DELETE", contentType, null); + } + @NotNull public static RequestBuilder post(@NotNull String url, @Nullable String contentType) { return requestWithBody(url, "POST", contentType, null); @@ -525,8 +530,8 @@ public final class HttpRequests { if (builder.myThrowStatusCodeException) { URLConnection connection = request.myConnection; - if (connection instanceof HttpURLConnection && ((HttpURLConnection)connection).getRequestMethod().equals("POST")) { - // getResponseCode is not checked on connect for POST, because write must be performed before read + if (connection.getDoOutput()) { + // getResponseCode is not checked on connect, because write must be performed before read HttpURLConnection urlConnection = (HttpURLConnection)connection; int responseCode = urlConnection.getResponseCode(); if (responseCode >= 400) { @@ -596,12 +601,13 @@ public final class HttpRequests { return connection; } - HttpURLConnection httpURLConnection = (HttpURLConnection)connection; - String method = httpURLConnection.getRequestMethod(); - if (method.equals("POST") || method.equals("PUT")) { + if (connection.getDoOutput()) { return connection; } + HttpURLConnection httpURLConnection = (HttpURLConnection)connection; + String method = httpURLConnection.getRequestMethod(); + LOG.assertTrue(method.equals("GET") || method.equals("HEAD") || method.equals("DELETE"), "'" + method + "' not supported; please use GET, HEAD, DELETE, PUT or POST"); diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequest.kt b/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequest.kt index 0480abd25b4e..b47805bf3273 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequest.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequest.kt @@ -48,9 +48,6 @@ sealed class GithubApiRequest(val url: String) { inline fun json(url: String, acceptMimeType: String? = null): Get = Json(url, T::class.java, acceptMimeType) - inline fun jsonList(url: String, acceptMimeType: String? = null): Get> = - JsonList(url, T::class.java, acceptMimeType) - inline fun jsonPage(url: String, acceptMimeType: String? = null): Get> = JsonPage(url, T::class.java, acceptMimeType) @@ -133,6 +130,8 @@ sealed class GithubApiRequest(val url: String) { companion object { inline fun json(url: String, body: Any? = null): Put = Json(url, body, T::class.java) + + inline fun jsonList(url: String, body: Any): Put> = JsonList(url, T::class.java, body) } open class Json(url: String, body: Any?, clazz: Class) : Put(body?.let { GithubApiContentHelper.toJson(it) }, @@ -143,6 +142,17 @@ sealed class GithubApiRequest(val url: String) { override fun extractResult(response: GithubApiResponse): T = parseJsonResponse(response, typeToken) } + + open class JsonList(url: String, clazz: Class, body: Any?) + : Put>(body?.let { GithubApiContentHelper.toJson(it) }, + GithubApiContentHelper.JSON_MIME_TYPE, + url, + GithubApiContentHelper.V3_JSON_MIME_TYPE) { + + private val typeToken = TypeToken.getParameterized(List::class.java, clazz) as TypeToken> + + override fun extractResult(response: GithubApiResponse): List = parseJsonResponse(response, typeToken) + } } abstract class Patch @JvmOverloads constructor(override val body: String, @@ -163,10 +173,23 @@ sealed class GithubApiRequest(val url: String) { } } - open class Delete(url: String) : GithubApiRequest(url) { - override val acceptMimeType: String? = null + abstract class Delete @JvmOverloads constructor(override val body: String?, + override val bodyMimeType: String, + url: String, + override val acceptMimeType: String? = null) : GithubApiRequest.WithBody(url) { - override fun extractResult(response: GithubApiResponse) {} + companion object { + inline fun json(url: String, body: Any? = null): Delete = Json(url, body, T::class.java) + } + + open class Json(url: String, body: Any? = null, clazz: Class) : Delete(body?.let { GithubApiContentHelper.toJson(it) }, + GithubApiContentHelper.JSON_MIME_TYPE, + url, + GithubApiContentHelper.V3_JSON_MIME_TYPE) { + private val typeToken = TypeToken.get(clazz) + + override fun extractResult(response: GithubApiResponse): T = parseJsonResponse(response, typeToken) + } } companion object { diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequestExecutor.kt b/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequestExecutor.kt index a0d8cdf4cd4e..2f988245ea35 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequestExecutor.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequestExecutor.kt @@ -149,7 +149,9 @@ sealed class GithubApiRequestExecutor { is GithubApiRequest.Put -> HttpRequests.put(request.url, request.bodyMimeType) is GithubApiRequest.Patch -> HttpRequests.patch(request.url, request.bodyMimeType) is GithubApiRequest.Head -> HttpRequests.head(request.url) - is GithubApiRequest.Delete -> HttpRequests.delete(request.url) + is GithubApiRequest.Delete -> { + if (request.body == null) HttpRequests.delete(request.url) else HttpRequests.delete(request.url, request.bodyMimeType) + } else -> throw UnsupportedOperationException("${request.javaClass} is not supported") } .connectTimeout(githubSettings.connectionTimeout) diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequests.kt b/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequests.kt index e94577a520d8..b77dd9f91157 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequests.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequests.kt @@ -100,7 +100,7 @@ object GithubApiRequests { delete(getUrl(server, urlSuffix, "/$username/$repoName")).withOperationName("delete repository $username/$repoName") @JvmStatic - fun delete(url: String) = Delete(url).withOperationName("delete repository at $url") + fun delete(url: String) = Delete.json(url).withOperationName("delete repository at $url") object Branches : Entity("/branches") { @JvmStatic @@ -136,8 +136,50 @@ object GithubApiRequests { fun get(url: String) = Get.jsonPage(url).withOperationName("get forks") } + object Assignees : Entity("/assignees") { + + @JvmStatic + fun pages(server: GithubServerPath, username: String, repoName: String) = + GithubApiPagesLoader.Request(get(server, username, repoName), ::get) + + @JvmOverloads + @JvmStatic + fun get(server: GithubServerPath, username: String, repoName: String, pagination: GithubRequestPagination? = null) = + get(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, getQuery(pagination?.toString().orEmpty()))) + + @JvmStatic + fun get(url: String) = Get.jsonPage(url).withOperationName("get assignees") + } + + object Labels : Entity("/labels") { + + @JvmStatic + fun pages(server: GithubServerPath, username: String, repoName: String) = + GithubApiPagesLoader.Request(get(server, username, repoName), ::get) + + @JvmOverloads + @JvmStatic + fun get(server: GithubServerPath, username: String, repoName: String, pagination: GithubRequestPagination? = null) = + get(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, getQuery(pagination?.toString().orEmpty()))) + + @JvmStatic + fun get(url: String) = Get.jsonPage(url).withOperationName("get assignees") + } + object Collaborators : Entity("/collaborators") { + @JvmStatic + fun pages(server: GithubServerPath, username: String, repoName: String) = + GithubApiPagesLoader.Request(get(server, username, repoName), ::get) + + @JvmOverloads + @JvmStatic + fun get(server: GithubServerPath, username: String, repoName: String, pagination: GithubRequestPagination? = null) = + get(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, getQuery(pagination?.toString().orEmpty()))) + + @JvmStatic + fun get(url: String) = Get.jsonPage(url).withOperationName("get collaborators") + @JvmStatic fun add(server: GithubServerPath, username: String, repoName: String, collaborator: String) = Put.json(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, "/", collaborator)) @@ -180,6 +222,11 @@ object GithubApiRequests { Patch.json(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, "/", id), GithubChangeIssueStateRequest(if (open) "open" else "closed")) + @JvmStatic + fun updateAssignees(server: GithubServerPath, username: String, repoName: String, id: String, assignees: Collection) = + Patch.json(getUrl(server, Repos.urlSuffix, "/$username/$repoName", urlSuffix, "/", id), + GithubAssigneesCollectionRequest(assignees)) + object Comments : Entity("/comments") { @JvmStatic fun create(server: GithubServerPath, username: String, repoName: String, issueId: String, body: String) = @@ -203,6 +250,13 @@ object GithubApiRequests { fun get(url: String) = Get.jsonPage(url, GithubApiContentHelper.V3_HTML_JSON_MIME_TYPE) .withOperationName("get comments for issue") } + + object Labels : Entity("/labels") { + @JvmStatic + fun replace(server: GithubServerPath, username: String, repoName: String, issueId: String, labels: Collection) = + Put.jsonList(getUrl(server, Repos.urlSuffix, "/$username/$repoName", Issues.urlSuffix, "/", issueId, urlSuffix), + GithubLabelsCollectionRequest(labels)) + } } object PullRequests : Entity("/pulls") { @@ -265,6 +319,18 @@ object GithubApiRequests { .withOperationName("rebase and merge pull request ${pullRequest.number}") private fun getMergeUrl(pullRequest: GithubPullRequest) = pullRequest.url + "/merge" + + object Reviewers : Entity("/requested_reviewers") { + @JvmStatic + fun add(server: GithubServerPath, username: String, repoName: String, number: Long, reviewers: Collection) = + Post.json(getUrl(server, Repos.urlSuffix, "/$username/$repoName", PullRequests.urlSuffix, "/$number", urlSuffix), + GithubReviewersCollectionRequest(reviewers, listOf())) + + @JvmStatic + fun remove(server: GithubServerPath, username: String, repoName: String, number: Long, reviewers: Collection) = + Delete.json(getUrl(server, Repos.urlSuffix, "/$username/$repoName", PullRequests.urlSuffix, "/$number", urlSuffix), + GithubReviewersCollectionRequest(reviewers, listOf())) + } } } @@ -280,7 +346,7 @@ object GithubApiRequests { .withOperationName("get gist $id") @JvmStatic - fun delete(server: GithubServerPath, id: String) = Delete(getUrl(server, urlSuffix, "/$id")) + fun delete(server: GithubServerPath, id: String) = Delete.json(getUrl(server, urlSuffix, "/$id")) .withOperationName("delete gist $id") } diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubIssueLabel.java b/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubIssueLabel.java index bb33066d9192..67f200d6a135 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubIssueLabel.java +++ b/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubIssueLabel.java @@ -19,7 +19,7 @@ import org.jetbrains.io.mandatory.RestModel; @RestModel @SuppressWarnings("UnusedDeclaration") public class GithubIssueLabel { - private Long id; + @Mandatory private Long id; private String nodeId; private String url; @Mandatory private String name; @@ -35,4 +35,21 @@ public class GithubIssueLabel { public String getColor() { return color; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof GithubIssueLabel)) return false; + + GithubIssueLabel label = (GithubIssueLabel)o; + + if (!id.equals(label.id)) return false; + + return true; + } + + @Override + public int hashCode() { + return id.hashCode(); + } } diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubPermissions.java b/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubPermissions.java new file mode 100644 index 000000000000..94d34567b8e9 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubPermissions.java @@ -0,0 +1,24 @@ +// Copyright 2000-2019 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.api.data; + +import org.jetbrains.io.mandatory.Mandatory; +import org.jetbrains.io.mandatory.RestModel; + +@RestModel +public class GithubPermissions { + @Mandatory private Boolean admin; + @Mandatory private Boolean pull; + @Mandatory private Boolean push; + + public boolean isAdmin() { + return admin; + } + + public boolean isPull() { + return pull; + } + + public boolean isPush() { + return push; + } +} \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubRepoWithPermissions.java b/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubRepoWithPermissions.java index e35a9f3086f4..738149843d28 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubRepoWithPermissions.java +++ b/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubRepoWithPermissions.java @@ -5,33 +5,13 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.io.mandatory.Mandatory; import org.jetbrains.io.mandatory.RestModel; -//example/GithubRepoWithPermissions.json @RestModel @SuppressWarnings("UnusedDeclaration") public class GithubRepoWithPermissions extends GithubRepo { - @Mandatory private Permissions permissions; + @Mandatory private GithubPermissions permissions; @NotNull - public Permissions getPermissions() { + public GithubPermissions getPermissions() { return permissions; } - - @RestModel - public static class Permissions { - @Mandatory private Boolean admin; - @Mandatory private Boolean pull; - @Mandatory private Boolean push; - - public boolean isAdmin() { - return admin; - } - - public boolean isPull() { - return pull; - } - - public boolean isPush() { - return push; - } - } } diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubUserWithPermissions.java b/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubUserWithPermissions.java new file mode 100644 index 000000000000..978524c8936c --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/api/data/GithubUserWithPermissions.java @@ -0,0 +1,17 @@ +// Copyright 2000-2019 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.api.data; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.io.mandatory.Mandatory; +import org.jetbrains.io.mandatory.RestModel; + +@RestModel +@SuppressWarnings("UnusedDeclaration") +public class GithubUserWithPermissions extends GithubUser { + @Mandatory private GithubPermissions permissions; + + @NotNull + public GithubPermissions getPermissions() { + return permissions; + } +} diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/requests/GithubAssigneesCollectionRequest.java b/plugins/github/src/org/jetbrains/plugins/github/api/requests/GithubAssigneesCollectionRequest.java new file mode 100644 index 000000000000..d4147cfb17fb --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/api/requests/GithubAssigneesCollectionRequest.java @@ -0,0 +1,15 @@ +// Copyright 2000-2019 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.api.requests; + +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; + +@SuppressWarnings({"FieldCanBeLocal", "UnusedDeclaration"}) +public class GithubAssigneesCollectionRequest { + @NotNull private final Collection assignees; + + public GithubAssigneesCollectionRequest(@NotNull Collection assignees) { + this.assignees = assignees; + } +} diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/requests/GithubLabelsCollectionRequest.java b/plugins/github/src/org/jetbrains/plugins/github/api/requests/GithubLabelsCollectionRequest.java new file mode 100644 index 000000000000..4f2184393db4 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/api/requests/GithubLabelsCollectionRequest.java @@ -0,0 +1,15 @@ +// Copyright 2000-2019 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.api.requests; + +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; + +@SuppressWarnings({"FieldCanBeLocal", "UnusedDeclaration"}) +public class GithubLabelsCollectionRequest { + @NotNull private final Collection labels; + + public GithubLabelsCollectionRequest(@NotNull Collection labels) { + this.labels = labels; + } +} diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/requests/GithubReviewersCollectionRequest.java b/plugins/github/src/org/jetbrains/plugins/github/api/requests/GithubReviewersCollectionRequest.java new file mode 100644 index 000000000000..7e1376553ba7 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/api/requests/GithubReviewersCollectionRequest.java @@ -0,0 +1,18 @@ +// Copyright 2000-2019 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.api.requests; + +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; + +@SuppressWarnings({"FieldCanBeLocal", "UnusedDeclaration"}) +public class GithubReviewersCollectionRequest { + @NotNull private final Collection reviewers; + @NotNull private final Collection team_reviewers; + + public GithubReviewersCollectionRequest(@NotNull Collection reviewers, + @NotNull Collection team_reviewers) { + this.reviewers = reviewers; + this.team_reviewers = team_reviewers; + } +} diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/GithubPullRequestsComponentFactory.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/GithubPullRequestsComponentFactory.kt index d93acf134087..4c12a3e3c57c 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/GithubPullRequestsComponentFactory.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/GithubPullRequestsComponentFactory.kt @@ -25,6 +25,7 @@ import org.jetbrains.plugins.github.pullrequest.config.GithubPullRequestsProject import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTrackerImpl import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsDataLoader import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsLoader +import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsMetadataServiceImpl import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityServiceImpl import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsStateServiceImpl import org.jetbrains.plugins.github.pullrequest.ui.GithubPullRequestChangesComponent @@ -73,13 +74,16 @@ internal class GithubPullRequestsComponentFactory(private val project: Project, private val securityService = GithubPullRequestsSecurityServiceImpl(sharedProjectSettings, accountDetails, repoDetails) private val busyStateTracker = GithubPullRequestsBusyStateTrackerImpl() + private val metadataService = GithubPullRequestsMetadataServiceImpl(project, progressManager, dataLoader, busyStateTracker, + requestExecutor, + avatarIconsProviderFactory, account.server, repoDetails.fullPath) private val stateService = GithubPullRequestsStateServiceImpl(project, progressManager, dataLoader, busyStateTracker, requestExecutor, account.server, repoDetails.fullPath) private val changes = GithubPullRequestChangesComponent(project, pullRequestUiSettings).apply { diffAction.registerCustomShortcutSet(this@GithubPullRequestsComponent, this@GithubPullRequestsComponent) } - private val details = GithubPullRequestDetailsComponent(dataLoader, securityService, busyStateTracker, stateService, + private val details = GithubPullRequestDetailsComponent(dataLoader, securityService, busyStateTracker, metadataService, stateService, avatarIconsProviderFactory) private val preview = GithubPullRequestPreviewComponent(changes, details) diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/data/service/GithubPullRequestsMetadataService.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/data/service/GithubPullRequestsMetadataService.kt new file mode 100644 index 000000000000..bd15b36b0907 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/data/service/GithubPullRequestsMetadataService.kt @@ -0,0 +1,16 @@ +// Copyright 2000-2019 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 org.jetbrains.annotations.CalledInAwt +import javax.swing.JComponent + +interface GithubPullRequestsMetadataService { + @CalledInAwt + fun adjustReviewers(pullRequest: Long, parentComponent: JComponent) + + @CalledInAwt + fun adjustAssignees(pullRequest: Long, parentComponent: JComponent) + + @CalledInAwt + fun adjustLabels(pullRequest: Long, parentComponent: JComponent) +} \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/data/service/GithubPullRequestsMetadataServiceImpl.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/data/service/GithubPullRequestsMetadataServiceImpl.kt new file mode 100644 index 000000000000..4f952d4f6215 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/data/service/GithubPullRequestsMetadataServiceImpl.kt @@ -0,0 +1,311 @@ +// Copyright 2000-2019 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.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent +import com.intellij.openapi.ui.popup.PopupChooserBuilder +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.text.StringUtil +import com.intellij.ui.CollectionListModel +import com.intellij.ui.ColorUtil +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBList +import com.intellij.util.ui.ColorIcon +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.StatusText +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import org.jetbrains.plugins.github.api.GithubApiRequestExecutor +import org.jetbrains.plugins.github.api.GithubApiRequests +import org.jetbrains.plugins.github.api.GithubFullPath +import org.jetbrains.plugins.github.api.GithubServerPath +import org.jetbrains.plugins.github.api.data.GithubIssueLabel +import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailed +import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailedWithHtml +import org.jetbrains.plugins.github.api.data.GithubUser +import org.jetbrains.plugins.github.api.util.GithubApiPagesLoader +import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider +import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker +import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsDataLoader +import org.jetbrains.plugins.github.util.* +import java.awt.Component +import java.util.concurrent.CompletableFuture +import java.util.function.Consumer +import java.util.function.Function +import javax.swing.* + +class GithubPullRequestsMetadataServiceImpl internal constructor(private val project: Project, + private val progressManager: ProgressManager, + private val dataLoader: GithubPullRequestsDataLoader, + private val busyStateTracker: GithubPullRequestsBusyStateTracker, + private val requestExecutor: GithubApiRequestExecutor, + private val avatarIconsProviderFactory: CachingGithubAvatarIconsProvider.Factory, + private val serverPath: GithubServerPath, + private val repoPath: GithubFullPath) + : GithubPullRequestsMetadataService { + + private val repoCollaboratorsWithPushAccess: List by lazy { + GithubApiPagesLoader + .loadAll(requestExecutor, progressManager.progressIndicator, + GithubApiRequests.Repos.Collaborators.pages(serverPath, repoPath.user, repoPath.repository)) + .filter { it.permissions.isPush } + } + + private val repoIssuesAssignees: List by lazy { + GithubApiPagesLoader.loadAll(requestExecutor, progressManager.progressIndicator, + GithubApiRequests.Repos.Assignees.pages(serverPath, repoPath.user, repoPath.repository)) + } + + private val repoIssuesLabels: List by lazy { + GithubApiPagesLoader.loadAll(requestExecutor, progressManager.progressIndicator, + GithubApiRequests.Repos.Labels.pages(serverPath, repoPath.user, repoPath.repository)) + } + + override fun adjustReviewers(pullRequest: Long, parentComponent: JComponent) { + showUsersChooser(pullRequest, "Reviewers", parentComponent, + { _, details -> repoCollaboratorsWithPushAccess.filter { details.user != it } }) { it.requestedReviewers } + .handleOnEdt(getAdjustmentHandler(pullRequest, "reviewer") { delta, indicator -> + if (delta.removedItems.isNotEmpty()) { + indicator.text2 = "Removing reviewers" + requestExecutor.execute(indicator, + GithubApiRequests.Repos.PullRequests.Reviewers + .remove(serverPath, repoPath.user, repoPath.repository, pullRequest, + delta.removedItems.map { it.login })) + } + if (delta.newItems.isNotEmpty()) { + indicator.text2 = "Adding reviewers" + requestExecutor.execute(indicator, + GithubApiRequests.Repos.PullRequests.Reviewers + .add(serverPath, repoPath.user, repoPath.repository, pullRequest, + delta.newItems.map { it.login })) + } + }) + } + + override fun adjustAssignees(pullRequest: Long, parentComponent: JComponent) { + showUsersChooser(pullRequest, "Assignees", parentComponent, { _, _ -> repoIssuesAssignees }) { it.assignees } + .handleOnEdt(getAdjustmentHandler(pullRequest, "assignee") { delta, indicator -> + requestExecutor.execute(indicator, + GithubApiRequests.Repos.Issues + .updateAssignees(serverPath, repoPath.user, repoPath.repository, pullRequest.toString(), + delta.newCollection.map { it.login })) + }) + } + + override fun adjustLabels(pullRequest: Long, parentComponent: JComponent) { + showChooser(pullRequest, "Labels", parentComponent, + { SelectionListCellRenderer.Labels() }, { it.name }, + { _, _ -> repoIssuesLabels }, { it.labels.orEmpty() }) + .handleOnEdt(getAdjustmentHandler(pullRequest, "label") { delta, indicator -> + requestExecutor.execute(indicator, + GithubApiRequests.Repos.Issues.Labels + .replace(serverPath, repoPath.user, repoPath.repository, pullRequest.toString(), + delta.newCollection.map { it.name })) + }) + } + + private fun showUsersChooser(pullRequest: Long, + popupTitle: String, + parentComponent: JComponent, + availableListProvider: (ProgressIndicator, GithubPullRequestDetailed) -> List, + currentListExtractor: (GithubPullRequestDetailed) -> List) + : CompletableFuture> { + return showChooser(pullRequest, popupTitle, parentComponent, { list -> + val avatarIconsProvider = avatarIconsProviderFactory.create(JBUI.uiIntValue("GitHub.Avatars.Size", 20), list) + SelectionListCellRenderer.Users(avatarIconsProvider) + }, { it.login }, availableListProvider, currentListExtractor) + } + + private fun showChooser(pullRequest: Long, + popupTitle: String, + parentComponent: JComponent, + cellRendererFactory: (JList>) -> ListCellRenderer>, + speedSearchNamer: (T) -> String, + availableListProvider: (ProgressIndicator, GithubPullRequestDetailed) -> List, + currentListExtractor: (GithubPullRequestDetailed) -> List) + : CompletableFuture> { + + val listModel = CollectionListModel>() + val list = JBList>(listModel) + + val builder = PopupChooserBuilder>(list) + .setTitle(popupTitle) + .setResizable(true) + .setMovable(true) + .setNamerForFiltering { speedSearchNamer(it.value) } + .setAutoSelectIfEmpty(false) + .setCloseOnEnter(false) + .setRenderer(cellRendererFactory(list)) + .setItemsChosenCallback { + for (item in it) { + item.selected = !item.selected + } + list.repaint() + } + val popup = builder.createPopup() + + val updater = builder.backgroundUpdater + updater.paintBusy(true) + list.emptyText.text = "Loading..." + + var originalSelection: Set = setOf() + + val progressIndicator = EmptyProgressIndicator() + Disposer.register(popup, Disposable { progressIndicator.cancel() }) + + val loadingFuture = GithubAsyncUtil + .futureOfMutable { dataLoader.getDataProvider(pullRequest).detailsRequest } + .thenComposeAsync(Function { details: GithubPullRequestDetailedWithHtml -> + originalSelection = currentListExtractor(details).toHashSet() + progressManager.submitBackgroundTask(project, "Load List Of Possibilities", true, progressIndicator) { + availableListProvider(it, details) + } + }) + .thenAcceptAsync(Consumer { possibilities -> + listModel.replaceAll(possibilities + .map { SelectableWrapper(it, originalSelection.contains(it)) } + .sortedBy { !it.selected }) + + updater.paintBusy(false) + list.emptyText.text = StatusText.DEFAULT_EMPTY_TEXT + + popup.pack(true, true) + }, EDT_EXECUTOR) + .exceptionally { + updater.paintBusy(false) + list.emptyText.clear() + list.emptyText.appendText("Can't load the list", SimpleTextAttributes.ERROR_ATTRIBUTES) + list.emptyText.appendSecondaryText(it.message.orEmpty(), SimpleTextAttributes.ERROR_ATTRIBUTES, null) + throw it + } + + Disposer.register(popup, Disposable { + loadingFuture.cancel(true) + }) + + val result = CompletableFuture>() + popup.addListener(object : JBPopupListener { + override fun onClosed(event: LightweightWindowEvent) { + if (!loadingFuture.isDone || loadingFuture.isCancelled || loadingFuture.isCompletedExceptionally) { + result.cancel(true) + return + } + + val selected = listModel.items.filter { it.selected }.map { it.value } + result.complete(CollectionDelta(originalSelection, selected)) + } + }) + popup.showUnderneathOf(parentComponent) + return result + } + + private fun getAdjustmentHandler(pullRequest: Long, + entityName: String, + adjuster: (CollectionDelta, ProgressIndicator) -> Unit): (CollectionDelta?, Throwable?) -> Unit { + return handler@{ delta, error -> + if (error != null) { + if (!GithubAsyncUtil.isCancellation(error)) + GithubNotifications.showError(project, "Failed to adjust list of ${StringUtil.pluralize(entityName)}", error) + return@handler + } + if (delta == null || delta.isEmpty) { + return@handler + } + + if (!busyStateTracker.acquire(pullRequest)) return@handler + progressManager.run(object : Task.Backgroundable(project, "Adjusting List Of ${StringUtil.pluralize(entityName).capitalize()}", + true) { + override fun run(indicator: ProgressIndicator) { + adjuster(delta, indicator) + } + + override fun onThrowable(error: Throwable) { + GithubNotifications.showError(project, "Failed to adjust list of ${StringUtil.pluralize(entityName)}", error) + } + + override fun onFinished() { + busyStateTracker.release(pullRequest) + dataLoader.reloadDetails(pullRequest) + } + }) + } + } + + private data class SelectableWrapper(val value: T, var selected: Boolean = false) + + private sealed class SelectionListCellRenderer + : ListCellRenderer>, BorderLayoutPanel() { + + private val mainLabel = JLabel() + private val checkIconLabel = JLabel() + + init { + checkIconLabel.iconTextGap = JBUI.scale(UIUtil.DEFAULT_VGAP) + + addToLeft(checkIconLabel) + addToCenter(mainLabel) + + border = JBUI.Borders.empty(2) + } + + override fun getListCellRendererComponent(list: JList>, + value: SelectableWrapper, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean): Component { + font = list.font + foreground = UIUtil.getListForeground(isSelected, list.hasFocus()) + background = UIUtil.getListBackground(isSelected, list.hasFocus()) + + mainLabel.foreground = foreground + mainLabel.font = font + + mainLabel.text = getText(value.value) + mainLabel.icon = getIcon(value.value) + + if (value.selected) { + checkIconLabel.icon = AllIcons.Actions.Checked + checkIconLabel.border = JBUI.Borders.empty(0, 4) + } + else { + checkIconLabel.icon = null + checkIconLabel.border = JBUI.Borders.empty(0, 10) + } + + return this + } + + abstract fun getText(value: T): String + abstract fun getIcon(value: T): Icon + + class Users(private val iconsProvider: CachingGithubAvatarIconsProvider) + : SelectionListCellRenderer() { + + override fun getText(value: GithubUser) = value.login + override fun getIcon(value: GithubUser) = iconsProvider.getIcon(value) + + } + + class Labels + : SelectionListCellRenderer() { + + override fun getText(value: GithubIssueLabel) = value.name + override fun getIcon(value: GithubIssueLabel) = ColorIcon(16, ColorUtil.fromHex(value.color)) + + } + } + + private class CollectionDelta(oldCollection: Collection, val newCollection: Collection) { + val newItems: Collection = newCollection - oldCollection + val removedItems: Collection = oldCollection - newCollection + + val isEmpty = newItems.isEmpty() && removedItems.isEmpty() + } +} \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/GithubPullRequestDetailsComponent.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/GithubPullRequestDetailsComponent.kt index fe687c033d70..7386df0ed1da 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/GithubPullRequestDetailsComponent.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/GithubPullRequestDetailsComponent.kt @@ -14,6 +14,7 @@ import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailedWithHtml import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsDataLoader +import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsMetadataService import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityService import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsStateService import org.jetbrains.plugins.github.pullrequest.ui.details.GithubPullRequestDetailsModel @@ -23,12 +24,13 @@ import java.awt.BorderLayout internal class GithubPullRequestDetailsComponent(private val dataLoader: GithubPullRequestsDataLoader, securityService: GithubPullRequestsSecurityService, busyStateTracker: GithubPullRequestsBusyStateTracker, + metadataService: GithubPullRequestsMetadataService, stateService: GithubPullRequestsStateService, iconProviderFactory: CachingGithubAvatarIconsProvider.Factory) : GithubDataLoadingComponent(), Disposable { private val detailsModel = GithubPullRequestDetailsModel() - private val detailsPanel = GithubPullRequestDetailsPanel(detailsModel, securityService, busyStateTracker, stateService, + private val detailsPanel = GithubPullRequestDetailsPanel(detailsModel, securityService, busyStateTracker, metadataService, stateService, iconProviderFactory) private val loadingPanel = JBLoadingPanel(BorderLayout(), this, ProgressWindow.DEFAULT_PROGRESS_DIALOG_POSTPONE_TIME_MILLIS).apply { @@ -62,6 +64,8 @@ internal class GithubPullRequestDetailsComponent(private val dataLoader: GithubP dataLoader.reloadDetails(result.number) } } + validate() + repaint() } override fun handleError(error: Throwable) { diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/GithubPullRequestDetailsPanel.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/GithubPullRequestDetailsPanel.kt index 6300a32df779..95aaab807dee 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/GithubPullRequestDetailsPanel.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/GithubPullRequestDetailsPanel.kt @@ -17,6 +17,7 @@ import net.miginfocom.layout.LC import net.miginfocom.swing.MigLayout import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker +import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsMetadataService import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityService import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsStateService import java.awt.Graphics @@ -28,6 +29,7 @@ import javax.swing.JPanel internal class GithubPullRequestDetailsPanel(model: GithubPullRequestDetailsModel, securityService: GithubPullRequestsSecurityService, busyStateTracker: GithubPullRequestsBusyStateTracker, + metadataService: GithubPullRequestsMetadataService, stateService: GithubPullRequestsStateService, iconProviderFactory: CachingGithubAvatarIconsProvider.Factory) : JPanel(), ComponentWithEmptyText, Disposable { @@ -37,7 +39,7 @@ internal class GithubPullRequestDetailsPanel(model: GithubPullRequestDetailsMode } private val iconsProvider = iconProviderFactory.create(JBValue.UIInteger("Profile.Icon.Size", 20), this) - private val metaPanel = GithubPullRequestMetadataPanel(model, iconsProvider).apply { + private val metaPanel = GithubPullRequestMetadataPanel(model, securityService, busyStateTracker, metadataService, iconsProvider).apply { border = JBUI.Borders.empty(4, 8, 4, 8) } private val descriptionPanel = GithubPullRequestDescriptionPanel(model).apply { diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/GithubPullRequestMetadataPanel.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/GithubPullRequestMetadataPanel.kt index c96dd05974a0..b6a8d98c946f 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/GithubPullRequestMetadataPanel.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/GithubPullRequestMetadataPanel.kt @@ -2,6 +2,7 @@ package org.jetbrains.plugins.github.pullrequest.ui.details import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.panels.Wrapper import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -9,21 +10,28 @@ import net.miginfocom.layout.CC import net.miginfocom.layout.LC import net.miginfocom.swing.MigLayout import org.jetbrains.plugins.github.api.data.GithubIssueLabel +import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailed import org.jetbrains.plugins.github.api.data.GithubUser import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider +import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker +import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsMetadataService +import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityService import org.jetbrains.plugins.github.util.GithubUIUtil import javax.swing.JLabel import javax.swing.JPanel import javax.swing.SwingConstants internal class GithubPullRequestMetadataPanel(private val model: GithubPullRequestDetailsModel, + private val securityService: GithubPullRequestsSecurityService, + private val busyStateTracker: GithubPullRequestsBusyStateTracker, + private val metadataService: GithubPullRequestsMetadataService, private val iconsProvider: CachingGithubAvatarIconsProvider) : JPanel(), Disposable { private val directionPanel = GithubPullRequestDirectionPanel() - private val reviewersHandle = LabeledListPanelHandle.create("No Reviewers", "Reviewers:", ::createUserLabel) - private val assigneesHandle = LabeledListPanelHandle.create("Unassigned", "Assignees:", ::createUserLabel) - private val labelsHandle = LabeledListPanelHandle.create("No Labels", "Labels:", ::createLabelLabel) + private val reviewersHandle = ReviewersListPanelHandle() + private val assigneesHandle = AssigneesListPanelHandle() + private val labelsHandle = LabelsListPanelHandle() init { isOpaque = false @@ -42,10 +50,11 @@ internal class GithubPullRequestMetadataPanel(private val model: GithubPullReque model.addDetailsChangedListener(this) { directionPanel.direction = model.details?.let { it.head to it.base } - reviewersHandle.list = model.details?.requestedReviewers - assigneesHandle.list = model.details?.assignees - labelsHandle.list = model.details?.labels } + + Disposer.register(this, reviewersHandle) + Disposer.register(this, assigneesHandle) + Disposer.register(this, labelsHandle) } private fun addListPanel(handle: LabeledListPanelHandle<*>) { @@ -53,13 +62,48 @@ internal class GithubPullRequestMetadataPanel(private val model: GithubPullReque add(handle.panel, CC().minWidth("0").growX().pushX().wrap()) } + override fun dispose() {} + + private inner class ReviewersListPanelHandle + : LabeledListPanelHandle(model, securityService, busyStateTracker, "No Reviewers", "Reviewers:") { + override fun extractItems(details: GithubPullRequestDetailed): List = details.requestedReviewers + + override fun getItemComponent(item: GithubUser) = createUserLabel(item) + + override fun editList() { + model.details?.run { metadataService.adjustReviewers(number, editButton) } + } + } + + private inner class AssigneesListPanelHandle + : LabeledListPanelHandle(model, securityService, busyStateTracker, "Unassigned", "Assignees:") { + + override fun extractItems(details: GithubPullRequestDetailed): List = details.assignees + + override fun getItemComponent(item: GithubUser) = createUserLabel(item) + + override fun editList() { + model.details?.run { metadataService.adjustAssignees(number, editButton) } + } + } + private fun createUserLabel(user: GithubUser) = JLabel(user.login, iconsProvider.getIcon(user), SwingConstants.LEFT).apply { border = JBUI.Borders.empty(UIUtil.DEFAULT_VGAP, UIUtil.DEFAULT_HGAP / 2, UIUtil.DEFAULT_VGAP, UIUtil.DEFAULT_HGAP / 2) } + private inner class LabelsListPanelHandle + : LabeledListPanelHandle(model, securityService, busyStateTracker, "No Labels", "Labels:") { + + override fun extractItems(details: GithubPullRequestDetailed): List? = details.labels + + override fun getItemComponent(item: GithubIssueLabel) = createLabelLabel(item) + + override fun editList() { + model.details?.run { metadataService.adjustLabels(number, editButton) } + } + } + private fun createLabelLabel(label: GithubIssueLabel) = Wrapper(GithubUIUtil.createIssueLabelLabel(label)).apply { border = JBUI.Borders.empty(UIUtil.DEFAULT_VGAP + 1, UIUtil.DEFAULT_HGAP / 2, UIUtil.DEFAULT_VGAP + 2, UIUtil.DEFAULT_HGAP / 2) } - - override fun dispose() {} } \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/LabeledListPanelHandle.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/LabeledListPanelHandle.kt index cd6386a31705..c1cd96191e6e 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/LabeledListPanelHandle.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/details/LabeledListPanelHandle.kt @@ -1,39 +1,96 @@ // Copyright 2000-2019 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.ui.details +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.ui.popup.IconButton +import com.intellij.ui.InplaceButton import com.intellij.ui.components.panels.NonOpaquePanel +import com.intellij.util.IconUtil import com.intellij.util.ui.JBUI +import com.intellij.util.ui.JBUI.Panels.simplePanel import com.intellij.util.ui.UIUtil +import org.jetbrains.plugins.github.api.data.GithubPullRequestDetailed +import org.jetbrains.plugins.github.pullrequest.data.GithubPullRequestsBusyStateTracker +import org.jetbrains.plugins.github.pullrequest.data.service.GithubPullRequestsSecurityService import org.jetbrains.plugins.github.pullrequest.ui.WrapLayout import org.jetbrains.plugins.github.util.GithubUtil.Delegates.equalVetoingObservable +import java.awt.Cursor import java.awt.FlowLayout +import java.awt.event.ActionListener +import javax.swing.Icon import javax.swing.JComponent import javax.swing.JLabel -internal abstract class LabeledListPanelHandle(emptyText: String, notEmptyText: String) { +internal abstract class LabeledListPanelHandle(private val model: GithubPullRequestDetailsModel, + private val securityService: GithubPullRequestsSecurityService, + private val busyStateTracker: GithubPullRequestsBusyStateTracker, + emptyText: String, notEmptyText: String) + : Disposable { + val label = JLabel().apply { foreground = UIUtil.getContextHelpForeground() border = JBUI.Borders.empty(UIUtil.DEFAULT_VGAP + 2, 0, UIUtil.DEFAULT_VGAP + 2, UIUtil.DEFAULT_HGAP / 2) } val panel = NonOpaquePanel(WrapLayout(FlowLayout.LEADING, 0, 0)) - var list: List? by equalVetoingObservable?>(null) { newList -> + protected val editButton = InplaceButton(IconButton(null, + resizeSquareIcon(AllIcons.General.Inline_edit), + resizeSquareIcon(AllIcons.General.Inline_edit_hovered)), + ActionListener { (::editList)() }).apply { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + isVisible = securityService.isCurrentUserWithPushAccess() + } + + private fun resizeSquareIcon(icon: Icon): Icon { + val scale = 20f / icon.iconHeight + return IconUtil.scale(icon, editButton, scale) + } + + private var list: List? by equalVetoingObservable?>(null) { newList -> label.text = newList?.let { if (it.isEmpty()) emptyText else notEmptyText } label.isVisible = newList != null panel.removeAll() panel.isVisible = newList != null - if (newList != null) for (item in newList) { - panel.add(getListItemComponent(item)) + if (newList != null) { + if (newList.isEmpty()) { + panel.add(editButton) + } + else { + for (item in newList.dropLast(1)) { + panel.add(getListItemComponent(item)) + } + panel.add(getListItemComponent(newList.last(), true)) + } } } - abstract fun getListItemComponent(item: T): JComponent - - companion object { - inline fun create(emptyText: String, notEmptyText: String, crossinline componentProvider: (T) -> JComponent) = - object : LabeledListPanelHandle(emptyText, notEmptyText) { - override fun getListItemComponent(item: T) = componentProvider(item) - } + init { + model.addDetailsChangedListener(this) { + list = model.details?.let(::extractItems) + updateButton() + } + busyStateTracker.addPullRequestBusyStateListener(this) { + updateButton() + } } + + private fun updateButton() { + editButton.isEnabled = !(model.details?.number?.let(busyStateTracker::isBusy) ?: true) + } + + private fun getListItemComponent(item: T, last: Boolean = false) = + if (!last) getItemComponent(item) + else simplePanel(getItemComponent(item)).addToRight(editButton).apply { + isOpaque = false + } + + abstract fun extractItems(details: GithubPullRequestDetailed): List? + + abstract fun getItemComponent(item: T): JComponent + + abstract fun editList() + + override fun dispose() {} } \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/util/GithubAsyncUtil.kt b/plugins/github/src/org/jetbrains/plugins/github/util/GithubAsyncUtil.kt index 187ef60c073f..612ab57960ca 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/util/GithubAsyncUtil.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/util/GithubAsyncUtil.kt @@ -4,6 +4,9 @@ package org.jetbrains.plugins.github.util import com.intellij.openapi.application.runInEdt import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project import java.util.concurrent.* import java.util.function.BiFunction @@ -33,6 +36,23 @@ object GithubAsyncUtil { return result } + @JvmStatic + fun futureOfMutable(futureSupplier: () -> CompletableFuture): CompletableFuture { + val result = CompletableFuture() + handleToOtherIfCancelled(futureSupplier, result) + return result + } + + private fun handleToOtherIfCancelled(futureSupplier: () -> CompletableFuture, other: CompletableFuture) { + futureSupplier().handle { result, error -> + if (result != null) other.complete(result) + if (error != null) { + if (isCancellation(error)) handleToOtherIfCancelled(futureSupplier, other) + other.completeExceptionally(error.cause) + } + } + } + fun isCancellation(error: Throwable): Boolean { return error is ProcessCanceledException || error is CancellationException @@ -41,6 +61,28 @@ object GithubAsyncUtil { } } +fun ProgressManager.submitBackgroundTask(project: Project, + title: String, + canBeCancelled: Boolean, + progressIndicator: ProgressIndicator, + process: (indicator: ProgressIndicator) -> T): CompletableFuture { + val future = CompletableFuture() + runProcessWithProgressAsynchronously(object : Task.Backgroundable(project, title, canBeCancelled) { + override fun run(indicator: ProgressIndicator) { + future.complete(process(indicator)) + } + + override fun onCancel() { + future.cancel(true) + } + + override fun onThrowable(error: Throwable) { + future.completeExceptionally(error) + } + }, progressIndicator) + return future +} + fun CompletableFuture.handleOnEdt(handler: (T?, Throwable?) -> Unit): CompletableFuture = handleAsync(BiFunction { result: T?, error: Throwable? -> handler(result, error) diff --git a/plugins/github/src/org/jetbrains/plugins/github/util/GithubUIUtil.kt b/plugins/github/src/org/jetbrains/plugins/github/util/GithubUIUtil.kt index 12f7149375f1..048d1ae8e684 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/util/GithubUIUtil.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/util/GithubUIUtil.kt @@ -41,7 +41,7 @@ object GithubUIUtil { } } - fun createIssueLabelLabel(label: GithubIssueLabel): JBLabel = JBLabel(" ${label.name} ", UIUtil.ComponentStyle.MINI).apply { + fun createIssueLabelLabel(label: GithubIssueLabel): JBLabel = JBLabel(" ${label.name} ", UIUtil.ComponentStyle.SMALL).apply { val apiColor = ColorUtil.fromHex(label.color) background = JBColor(apiColor, ColorUtil.darker(apiColor, 3)) foreground = computeForeground(background)