From e76aabe924f964ffe5163645afc345fd8145bc5d Mon Sep 17 00:00:00 2001 From: Aleksandr Krasilnikov Date: Wed, 3 Jul 2019 15:40:15 +0300 Subject: [PATCH] github: implement clone dialog extension GitOrigin-RevId: d49a6e291e56c41374eb4ffd83b074cf04d3396a --- .../ui/cloneDialog/VcsCloneDialogUiSpec.kt | 1 + plugins/github/resources/META-INF/plugin.xml | 2 + .../plugins/github/GithubShareAction.kt | 3 +- .../plugins/github/api/GithubApiRequests.kt | 40 +- .../github/api/GithubRepositoryPath.kt | 2 +- .../plugins/github/api/GithubServerPath.java | 8 +- .../api/data/request/GHRepoRequestParams.kt | 48 ++ .../data/request/GithubRequestPagination.kt | 5 + .../github/api/util/GithubApiPagesLoader.kt | 13 +- .../GithubAuthenticationManager.kt | 16 +- .../GithubAccountInformationProvider.kt | 8 + .../authentication/ui/GithubCredentialsUI.kt | 37 +- .../authentication/ui/GithubLoginPanel.kt | 21 + .../cloneDialog/GHCloneDialogAccountsPanel.kt | 110 +++++ .../ui/cloneDialog/GHCloneDialogExtension.kt | 48 ++ .../GHCloneDialogExtensionComponent.kt | 455 ++++++++++++++++++ .../ui/cloneDialog/GHLoginController.kt | 12 + .../github/ui/cloneDialog/GHRepositoryList.kt | 114 +++++ .../ui/cloneDialog/GHRepositoryListItem.kt | 110 +++++ .../util/CachingGithubUserAvatarLoader.kt | 6 + .../plugins/github/util/GithubImageResizer.kt | 6 + .../github/GithubRequestQueryingTest.kt | 9 +- 22 files changed, 1053 insertions(+), 21 deletions(-) create mode 100644 plugins/github/src/org/jetbrains/plugins/github/api/data/request/GHRepoRequestParams.kt create mode 100644 plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogAccountsPanel.kt create mode 100644 plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogExtension.kt create mode 100644 plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogExtensionComponent.kt create mode 100644 plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHLoginController.kt create mode 100644 plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHRepositoryList.kt create mode 100644 plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHRepositoryListItem.kt diff --git a/platform/vcs-impl/src/com/intellij/util/ui/cloneDialog/VcsCloneDialogUiSpec.kt b/platform/vcs-impl/src/com/intellij/util/ui/cloneDialog/VcsCloneDialogUiSpec.kt index 3340a031e464..cbf00328207f 100644 --- a/platform/vcs-impl/src/com/intellij/util/ui/cloneDialog/VcsCloneDialogUiSpec.kt +++ b/platform/vcs-impl/src/com/intellij/util/ui/cloneDialog/VcsCloneDialogUiSpec.kt @@ -26,5 +26,6 @@ object VcsCloneDialogUiSpec { object Components { // insets for component without full-height scrollbar val rightInsets = JBUI.insetsRight((UIUtil.PANEL_REGULAR_INSETS.right)) + val avatarSize = JBValue.UIInteger("VcsCloneDialog.Accounts.AvatarSize", 22) } } diff --git a/plugins/github/resources/META-INF/plugin.xml b/plugins/github/resources/META-INF/plugin.xml index 504a78f07b97..25dbc5364518 100644 --- a/plugins/github/resources/META-INF/plugin.xml +++ b/plugins/github/resources/META-INF/plugin.xml @@ -45,6 +45,8 @@ implementation="org.jetbrains.plugins.github.authentication.accounts.GithubAccountsStatisticsCollector"/> + diff --git a/plugins/github/src/org/jetbrains/plugins/github/GithubShareAction.kt b/plugins/github/src/org/jetbrains/plugins/github/GithubShareAction.kt index 62c34e85c516..e420ea376b73 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/GithubShareAction.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/GithubShareAction.kt @@ -50,6 +50,7 @@ import org.jetbrains.annotations.NonNls import org.jetbrains.annotations.TestOnly import org.jetbrains.plugins.github.api.GithubApiRequestExecutorManager import org.jetbrains.plugins.github.api.GithubApiRequests +import org.jetbrains.plugins.github.api.data.request.Type import org.jetbrains.plugins.github.api.util.GithubApiPagesLoader import org.jetbrains.plugins.github.authentication.GithubAuthenticationManager import org.jetbrains.plugins.github.authentication.accounts.GithubAccount @@ -136,7 +137,7 @@ class GithubShareAction : DumbAwareAction("Share Project on GitHub", "Easily sha val user = requestExecutor.execute(progressManager.progressIndicator, GithubApiRequests.CurrentUser.get(account.server)) val names = GithubApiPagesLoader .loadAll(requestExecutor, progressManager.progressIndicator, - GithubApiRequests.CurrentUser.Repos.pages(account.server, false)) + GithubApiRequests.CurrentUser.Repos.pages(account.server, Type.OWNER)) .mapSmartSet { it.name } user.canCreatePrivateRepo() to names }, "Loading Account Information For $account", true, project) 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 e2e10eed3464..e593913e36fc 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequests.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/api/GithubApiRequests.kt @@ -35,14 +35,27 @@ object GithubApiRequests { object Repos : Entity("/repos") { @JvmOverloads @JvmStatic - fun pages(server: GithubServerPath, allAssociated: Boolean = true, pagination: GithubRequestPagination? = null) = - GithubApiPagesLoader.Request(get(server, allAssociated, pagination), ::get) + fun pages(server: GithubServerPath, + type: Type = Type.DEFAULT, + visibility: Visibility = Visibility.DEFAULT, + affiliation: Affiliation = Affiliation.DEFAULT, + pagination: GithubRequestPagination? = null) = + GithubApiPagesLoader.Request(get(server, type, visibility, affiliation, pagination), ::get) @JvmOverloads @JvmStatic - fun get(server: GithubServerPath, allAssociated: Boolean = true, pagination: GithubRequestPagination? = null) = - get(getUrl(server, CurrentUser.urlSuffix, urlSuffix, - getQuery(if (allAssociated) "" else "type=owner", pagination?.toString().orEmpty()))) + fun get(server: GithubServerPath, + type: Type = Type.DEFAULT, + visibility: Visibility = Visibility.DEFAULT, + affiliation: Affiliation = Affiliation.DEFAULT, + pagination: GithubRequestPagination? = null): GithubApiRequest> { + if (type != Type.DEFAULT && (visibility != Visibility.DEFAULT || affiliation != Affiliation.DEFAULT)) { + throw IllegalArgumentException("Param 'type' should not be used together with 'visibility' or 'affiliation'") + } + + return get(getUrl(server, CurrentUser.urlSuffix, urlSuffix, + getQuery(type.toString(), visibility.toString(), affiliation.toString(), pagination?.toString().orEmpty()))) + } @JvmStatic fun get(url: String) = Get.jsonPage(url).withOperationName("get user repositories") @@ -54,6 +67,18 @@ object GithubApiRequests { .withOperationName("create user repository") } + object Orgs : Entity("/orgs") { + @JvmOverloads + @JvmStatic + fun pages(server: GithubServerPath, pagination: GithubRequestPagination? = null) = + GithubApiPagesLoader.Request(get(server, pagination), ::get) + + fun get(server: GithubServerPath, pagination: GithubRequestPagination? = null) = + get(getUrl(server, CurrentUser.urlSuffix, urlSuffix, getQuery(pagination?.toString().orEmpty()))) + + fun get(url: String) = Get.jsonPage(url).withOperationName("get user organizations") + } + object RepoSubs : Entity("/subscriptions") { @JvmStatic fun pages(server: GithubServerPath) = GithubApiPagesLoader.Request(get(server), ::get) @@ -72,12 +97,13 @@ object GithubApiRequests { object Repos : Entity("/repos") { @JvmStatic - fun pages(server: GithubServerPath, organisation: String) = GithubApiPagesLoader.Request(get(server, organisation), ::get) + fun pages(server: GithubServerPath, organisation: String, pagination: GithubRequestPagination? = null) = + GithubApiPagesLoader.Request(get(server, organisation, pagination), ::get) @JvmOverloads @JvmStatic fun get(server: GithubServerPath, organisation: String, pagination: GithubRequestPagination? = null) = - get(getUrl(server, Organisations.urlSuffix, "/", organisation, urlSuffix, pagination?.toString().orEmpty())) + get(getUrl(server, Organisations.urlSuffix, "/", organisation, urlSuffix, getQuery(pagination?.toString().orEmpty()))) @JvmStatic fun get(url: String) = Get.jsonPage(url).withOperationName("get organisation repositories") diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/GithubRepositoryPath.kt b/plugins/github/src/org/jetbrains/plugins/github/api/GithubRepositoryPath.kt index 1705a5deeb5c..b00a5d9a5892 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/GithubRepositoryPath.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/api/GithubRepositoryPath.kt @@ -1,7 +1,7 @@ // Copyright 2000-2018 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 class GithubRepositoryPath(private val serverPath: GithubServerPath, private val repositoryPath: GithubFullPath) { +data class GithubRepositoryPath(internal val serverPath: GithubServerPath, internal val repositoryPath: GithubFullPath) { fun toUrl(): String { return serverPath.toUrl() + "/" + repositoryPath.fullName } diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/GithubServerPath.java b/plugins/github/src/org/jetbrains/plugins/github/api/GithubServerPath.java index 26c8dfee3875..246fe0f82dd6 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/GithubServerPath.java +++ b/plugins/github/src/org/jetbrains/plugins/github/api/GithubServerPath.java @@ -120,7 +120,7 @@ public class GithubServerPath { @NotNull public String toApiUrl() { StringBuilder builder = new StringBuilder(getSchemaUrlPart()); - if (myHost.equalsIgnoreCase(DEFAULT_HOST)) { + if (isGithubDotCom()) { builder.append(API_PREFIX).append(myHost).append(getPortUrlPart()).append(StringUtil.notNullize(mySuffix)); } else { @@ -133,7 +133,7 @@ public class GithubServerPath { @NotNull public String toGraphQLUrl() { StringBuilder builder = new StringBuilder(getSchemaUrlPart()); - if (myHost.equalsIgnoreCase(DEFAULT_HOST)) { + if (isGithubDotCom()) { builder.append(API_PREFIX).append(myHost).append(getPortUrlPart()).append(StringUtil.notNullize(mySuffix)).append(GRAPHQL_SUFFIX); } else { @@ -142,6 +142,10 @@ public class GithubServerPath { return builder.toString(); } + public boolean isGithubDotCom() { + return myHost.equalsIgnoreCase(DEFAULT_HOST); + } + public String toString() { String schema = myUseHttp != null ? getSchemaUrlPart() : ""; return schema + myHost + getPortUrlPart() + StringUtil.notNullize(mySuffix); diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/data/request/GHRepoRequestParams.kt b/plugins/github/src/org/jetbrains/plugins/github/api/data/request/GHRepoRequestParams.kt new file mode 100644 index 000000000000..4e1e1bc95301 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/api/data/request/GHRepoRequestParams.kt @@ -0,0 +1,48 @@ +// 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.request + +/** + * Additional params for [`GET /user/repos`](https://developer.github.com/v3/repos/#list-your-repositories) request + */ +enum class Type(private val value: String) { + ALL(""), + OWNER("owner"), + PUBLIC("public"), + PRIVATE("private"), + @Suppress("unused") // API + MEMBER("member"); + + companion object { + val DEFAULT = ALL + } + + override fun toString() = if (value.isEmpty()) value else "type=$value" +} + +enum class Visibility(private val value: String) { + ALL(""), + PUBLIC("public"), + PRIVATE("private"); + + companion object { + val DEFAULT = ALL + } + + override fun toString() = if (value.isEmpty()) value else "visibility=$value" +} + +class Affiliation private constructor(private val value: String) { + companion object { + val OWNER = Affiliation("owner") + val COLLABORATOR = Affiliation("collaborator") + @Suppress("unused") // API + val ORG_MEMBER = Affiliation("organization_member") + val DEFAULT = Affiliation("") + + fun combine(vararg affiliations: Affiliation): Affiliation { + return Affiliation(affiliations.toSet().joinToString(",") { it.value }) + } + } + + override fun toString() = if (value.isEmpty()) value else "affiliation=$value" +} diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/data/request/GithubRequestPagination.kt b/plugins/github/src/org/jetbrains/plugins/github/api/data/request/GithubRequestPagination.kt index f41290c5e32e..05c1f37c2f48 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/data/request/GithubRequestPagination.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/api/data/request/GithubRequestPagination.kt @@ -7,6 +7,11 @@ class GithubRequestPagination @JvmOverloads constructor(val pageNumber: Int = 1, } companion object { + /** + * Max page size + */ const val DEFAULT_PAGE_SIZE = 100 + + val DEFAULT = GithubRequestPagination() } } diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/util/GithubApiPagesLoader.kt b/plugins/github/src/org/jetbrains/plugins/github/api/util/GithubApiPagesLoader.kt index 82dcfd1560ee..86a9106d8027 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/api/util/GithubApiPagesLoader.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/api/util/GithubApiPagesLoader.kt @@ -14,13 +14,22 @@ object GithubApiPagesLoader { @JvmStatic fun loadAll(executor: GithubApiRequestExecutor, indicator: ProgressIndicator, pagesRequest: Request): List { val result = mutableListOf() + loadAll(executor, indicator, pagesRequest) { result.addAll(it) } + return result + } + + @Throws(IOException::class) + @JvmStatic + fun loadAll(executor: GithubApiRequestExecutor, + indicator: ProgressIndicator, + pagesRequest: Request, + pageItemsConsumer: (List) -> Unit) { var request: GithubApiRequest>? = pagesRequest.initialRequest while (request != null) { val page = executor.execute(indicator, request) - result.addAll(page.items) + pageItemsConsumer(page.items) request = page.nextLink?.let(pagesRequest.urlRequestProvider) } - return result } @Throws(IOException::class) diff --git a/plugins/github/src/org/jetbrains/plugins/github/authentication/GithubAuthenticationManager.kt b/plugins/github/src/org/jetbrains/plugins/github/authentication/GithubAuthenticationManager.kt index fbfbbd273031..14759c1e3a47 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/authentication/GithubAuthenticationManager.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/authentication/GithubAuthenticationManager.kt @@ -78,7 +78,17 @@ class GithubAuthenticationManager internal constructor(private val accountManage return registerAccount(dialog.getLogin(), dialog.getServer(), dialog.getToken()) } - private fun isAccountUnique(name: String, server: GithubServerPath) = accountManager.accounts.none { it.name == name && it.server == server } + internal fun isAccountUnique(name: String, server: GithubServerPath) = accountManager.accounts.none { it.name == name && it.server == server } + + @CalledInAwt + internal fun removeAccount(githubAccount: GithubAccount) { + accountManager.accounts -= githubAccount + } + + @CalledInAwt + internal fun updateAccountToken(account: GithubAccount, newToken: String) { + accountManager.updateAccountToken(account, newToken) + } private fun registerAccount(name: String, server: GithubServerPath, token: String): GithubAccount { val account = GithubAccountManager.createAccount(name, server) @@ -87,8 +97,8 @@ class GithubAuthenticationManager internal constructor(private val accountManage return account } - @TestOnly - fun registerAccount(name: String, host: String, token: String): GithubAccount { + @CalledInAwt + internal fun registerAccount(name: String, host: String, token: String): GithubAccount { return registerAccount(name, GithubServerPath.from(host), token) } diff --git a/plugins/github/src/org/jetbrains/plugins/github/authentication/accounts/GithubAccountInformationProvider.kt b/plugins/github/src/org/jetbrains/plugins/github/authentication/accounts/GithubAccountInformationProvider.kt index 2dc16f00c220..5efdc7a8911a 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/authentication/accounts/GithubAccountInformationProvider.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/authentication/accounts/GithubAccountInformationProvider.kt @@ -3,6 +3,7 @@ package org.jetbrains.plugins.github.authentication.accounts import com.google.common.cache.CacheBuilder import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service import com.intellij.openapi.progress.ProgressIndicator import org.jetbrains.annotations.CalledInBackground import org.jetbrains.plugins.github.api.GithubApiRequestExecutor @@ -36,4 +37,11 @@ class GithubAccountInformationProvider { fun getInformation(executor: GithubApiRequestExecutor, indicator: ProgressIndicator, account: GithubAccount): GithubAuthenticatedUser { return informationCache.get(account) { executor.execute(indicator, GithubApiRequests.CurrentUser.get(account.server)) } } + + companion object { + @JvmStatic + fun getInstance(): GithubAccountInformationProvider { + return service() + } + } } \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GithubCredentialsUI.kt b/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GithubCredentialsUI.kt index b302cef94843..0e0d377b5e6b 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GithubCredentialsUI.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GithubCredentialsUI.kt @@ -18,8 +18,10 @@ import org.jetbrains.plugins.github.exceptions.GithubAuthenticationException import org.jetbrains.plugins.github.exceptions.GithubParseException import org.jetbrains.plugins.github.ui.util.DialogValidationUtils import org.jetbrains.plugins.github.ui.util.Validator +import java.awt.event.ActionListener import java.net.UnknownHostException import java.util.function.Supplier +import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.JPasswordField @@ -36,6 +38,25 @@ sealed class GithubCredentialsUI { abstract fun handleAcquireError(error: Throwable): ValidationInfo abstract fun setBusy(busy: Boolean) + protected val loginButton = JButton("Log In").apply { isVisible = false } + protected val cancelButton = JButton("Cancel").apply { isVisible = false } + + fun setLoginAction(actionListener: ActionListener) { + loginButton.addActionListener(actionListener) + } + + fun setCancelAction(actionListener: ActionListener) { + cancelButton.addActionListener(actionListener) + } + + fun setLoginButtonVisible(visible: Boolean) { + loginButton.isVisible = visible + } + + fun setCancelButtonVisible(visible: Boolean) { + cancelButton.isVisible = visible + } + internal class PasswordUI(private val serverTextField: ExtendableTextField, private val clientName: String, switchUi: () -> Unit, @@ -58,7 +79,7 @@ sealed class GithubCredentialsUI { override fun getPanel(): JPanel = JBUI.Panels.simplePanel() .addToTop(panel { row { - if (!dialogMode) label("Log in to GitHub", bold = true) + if (!dialogMode) label("Log In to GitHub", bold = true) right { switchUiLink() } } }) @@ -66,6 +87,12 @@ sealed class GithubCredentialsUI { row("Server:") { serverTextField() } row("Login:") { loginTextField() } row("Password:") { passwordField(comment = "Password is not saved and used only to acquire GitHub token") } + row("") { + cell { + loginButton() + cancelButton() + } + } }) override fun getPreferredFocus() = if (loginTextField.isEditable && loginTextField.text.isEmpty()) loginTextField else passwordField @@ -132,13 +159,19 @@ sealed class GithubCredentialsUI { override fun getPanel() = JBUI.Panels.simplePanel() .addToTop(panel { row { - if (!dialogMode) label("Log in to GitHub", bold = true) + if (!dialogMode) label("Log In to GitHub", bold = true) right { switchUiLink() } } }) .addToCenter(panel { row("Server:") { serverTextField() } row("Token:") { tokenTextField() } + row("") { + cell { + loginButton() + cancelButton() + } + } }) diff --git a/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GithubLoginPanel.kt b/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GithubLoginPanel.kt index 5b9b06a1e1d9..428fca2c8588 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GithubLoginPanel.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/authentication/ui/GithubLoginPanel.kt @@ -18,6 +18,7 @@ import org.jetbrains.plugins.github.ui.util.DialogValidationUtils import org.jetbrains.plugins.github.ui.util.Validator import org.jetbrains.plugins.github.util.GithubAsyncUtil import org.jetbrains.plugins.github.util.submitBackgroundTask +import java.awt.event.ActionListener import java.util.concurrent.CompletableFuture import javax.swing.JTextField @@ -140,4 +141,24 @@ class GithubLoginPanel(executorFactory: GithubApiRequestExecutor.Factory, fun setError(exception: Throwable) { tokenAcquisitionError = currentUi.handleAcquireError(exception) } + + fun setLoginListener(listener: ActionListener) { + passwordUi.setLoginAction(listener) + tokenUi.setLoginAction(listener) + } + + fun setCancelListener(listener: ActionListener) { + passwordUi.setCancelAction(listener) + tokenUi.setCancelAction(listener) + } + + fun setLoginButtonVisible(visible: Boolean) { + passwordUi.setLoginButtonVisible(visible) + tokenUi.setLoginButtonVisible(visible) + } + + fun setCancelButtonVisible(visible: Boolean) { + passwordUi.setCancelButtonVisible(visible) + tokenUi.setCancelButtonVisible(visible) + } } \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogAccountsPanel.kt b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogAccountsPanel.kt new file mode 100644 index 000000000000..0154e1f77a7c --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogAccountsPanel.kt @@ -0,0 +1,110 @@ +// 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.ui.cloneDialog + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.ui.JBPopupMenu +import com.intellij.ui.JBColor +import com.intellij.ui.SizedIcon +import com.intellij.ui.components.JBMenu +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.cloneDialog.VcsCloneDialogUiSpec +import icons.GithubIcons +import org.jetbrains.plugins.github.api.GithubApiRequestExecutorManager +import org.jetbrains.plugins.github.api.data.GithubUser +import org.jetbrains.plugins.github.authentication.GithubAuthenticationManager +import org.jetbrains.plugins.github.authentication.accounts.GithubAccount +import org.jetbrains.plugins.github.pullrequest.avatars.CachingGithubAvatarIconsProvider +import org.jetbrains.plugins.github.util.CachingGithubUserAvatarLoader +import org.jetbrains.plugins.github.util.GithubImageResizer +import java.awt.FlowLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Icon +import javax.swing.JLabel +import javax.swing.JPanel + +internal class GHCloneDialogAccountsPanel( + private val loginController: GHLoginController, + private val authenticationManager: GithubAuthenticationManager, + private val apiRequestExecutorManager: GithubApiRequestExecutorManager, + private val avatarLoader: CachingGithubUserAvatarLoader, + private val imageResizer: GithubImageResizer +) : JPanel(FlowLayout(FlowLayout.LEADING, JBUI.scale(1), 0)) { + + private val avatarSize = VcsCloneDialogUiSpec.Components.avatarSize + private val defaultIcon = SizedIcon(GithubIcons.DefaultAvatar, avatarSize.get(), avatarSize.get()) + + private val accountComponents = HashMap() + private val userDetails = HashMap>() + + private val popupMenuMouseAdapter = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent?) = showPopupMenu() + } + + init { + addMouseListener(popupMenuMouseAdapter) + } + + fun addAccount(account: GithubAccount) { + if (accountComponents.isEmpty()) removeAll() + + val label = accountComponents.getOrPut(account, { + JLabel().apply { + border = JBUI.Borders.customLine(JBColor.lightGray) + icon = defaultIcon + toolTipText = account.name + isOpaque = !isOpaque + addMouseListener(popupMenuMouseAdapter) + } + }) + add(label) + } + + fun removeAccount(removedAccount: GithubAccount) { + remove(accountComponents.remove(removedAccount)) + userDetails.remove(removedAccount) + + revalidate() + repaint() + } + + fun updateUserDetails(account: GithubAccount, user: GithubUser) { + val iconsProvider = CachingGithubAvatarIconsProvider.Factory( + avatarLoader, + imageResizer, + apiRequestExecutorManager.getExecutor(account) + ).create(avatarSize, this) + val avatar = iconsProvider.getIcon(user.avatarUrl) + userDetails[account] = Pair(user, avatar) + accountComponents[account]?.icon = avatar + } + + private fun showPopupMenu() { + // TODO: replace with custom popup action list + val popupMenu = JBPopupMenu() + for (account in authenticationManager.getAccounts()) { + val accountSubmenu = JBMenu().apply { + val pair = userDetails[account] + if (pair == null) { + text = account.name + icon = defaultIcon + add("Log in").addActionListener { loginController.reLogin(account) } + addSeparator() + add("Remove account").addActionListener { loginController.logout(account) } + } + else { + val (user, avatar) = pair + text = if (account.server.isGithubDotCom) account.name else ("${account.server.host}/${user.login}") + icon = avatar + add("Open on GitHub").addActionListener { BrowserUtil.browse(user.htmlUrl) } + addSeparator() + add("Log Out\u2026").addActionListener { loginController.logout(account) } + } + } + popupMenu.add(accountSubmenu) + } + popupMenu.addSeparator() + popupMenu.add("Add account\u2026").addActionListener { loginController.addAccount() } + popupMenu.show(this, 0, bounds.maxY.toInt()) + } +} \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogExtension.kt b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogExtension.kt new file mode 100644 index 000000000000..6cd67a534e38 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogExtension.kt @@ -0,0 +1,48 @@ +// 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.ui.cloneDialog + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtension +import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtensionComponent +import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtensionStatusLine +import org.jetbrains.plugins.github.api.GithubApiRequestExecutor +import org.jetbrains.plugins.github.api.GithubApiRequestExecutorManager +import org.jetbrains.plugins.github.authentication.GithubAuthenticationManager +import org.jetbrains.plugins.github.authentication.accounts.GithubAccountInformationProvider +import org.jetbrains.plugins.github.util.CachingGithubUserAvatarLoader +import org.jetbrains.plugins.github.util.GithubImageResizer +import org.jetbrains.plugins.github.util.GithubUtil +import javax.swing.Icon + +class GHCloneDialogExtension : VcsCloneDialogExtension { + private val authenticationManager = GithubAuthenticationManager.getInstance() + + override fun getName() = GithubUtil.SERVICE_DISPLAY_NAME + + override fun getIcon(): Icon = AllIcons.Vcs.Vendors.Github + + override fun getAdditionalStatusLines(): List { + if (!authenticationManager.hasAccounts()) { + return listOf(VcsCloneDialogExtensionStatusLine.greyText("No accounts")) + } + + val list = ArrayList() + for (account in authenticationManager.getAccounts()) { + val accName = if (account.server.isGithubDotCom) account.name else ("${account.server.host}/${account.name}") + list.add(VcsCloneDialogExtensionStatusLine.greyText(accName)) + } + return list + } + + override fun createMainComponent(project: Project): VcsCloneDialogExtensionComponent { + return GHCloneDialogExtensionComponent(project, + GithubAuthenticationManager.getInstance(), + GithubApiRequestExecutorManager.getInstance(), + GithubApiRequestExecutor.Factory.getInstance(), + GithubAccountInformationProvider.getInstance(), + CachingGithubUserAvatarLoader.getInstance(), + GithubImageResizer.getInstance() + ) + } +} \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogExtensionComponent.kt b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogExtensionComponent.kt new file mode 100644 index 000000000000..dc37d32d5df7 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHCloneDialogExtensionComponent.kt @@ -0,0 +1,455 @@ +// 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.ui.cloneDialog + +import com.intellij.dvcs.repo.ClonePathProvider +import com.intellij.dvcs.ui.CloneDvcsValidationUtils +import com.intellij.dvcs.ui.DvcsBundle.getString +import com.intellij.dvcs.ui.SelectChildTextFieldWithBrowseButton +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.rd.attachChild +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.vcs.ProjectLevelVcsManager +import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtensionComponent +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.ui.* +import com.intellij.ui.components.panels.VerticalLayout +import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.layout.* +import com.intellij.ui.speedSearch.NameFilteringListModel +import com.intellij.ui.speedSearch.SpeedSearch +import com.intellij.util.containers.ContainerUtil +import com.intellij.util.progress.ProgressVisibilityManager +import com.intellij.util.ui.JBEmptyBorder +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import git4idea.GitUtil +import git4idea.checkout.GitCheckoutProvider +import git4idea.commands.Git +import git4idea.remote.GitRememberedInputs +import org.jetbrains.plugins.github.api.* +import org.jetbrains.plugins.github.api.data.GithubAuthenticatedUser +import org.jetbrains.plugins.github.api.data.GithubRepo +import org.jetbrains.plugins.github.api.data.request.Affiliation +import org.jetbrains.plugins.github.api.data.request.GithubRequestPagination +import org.jetbrains.plugins.github.api.util.GithubApiPagesLoader +import org.jetbrains.plugins.github.authentication.GithubAuthenticationManager +import org.jetbrains.plugins.github.authentication.accounts.* +import org.jetbrains.plugins.github.authentication.ui.GithubLoginPanel +import org.jetbrains.plugins.github.exceptions.GithubMissingTokenException +import org.jetbrains.plugins.github.util.CachingGithubUserAvatarLoader +import org.jetbrains.plugins.github.util.GithubImageResizer +import org.jetbrains.plugins.github.util.GithubUrlUtil +import org.jetbrains.plugins.github.util.handleOnEdt +import java.awt.event.ActionListener +import java.nio.file.Paths +import javax.swing.JPanel +import javax.swing.JSeparator +import javax.swing.event.DocumentEvent +import kotlin.properties.Delegates + +internal class GHCloneDialogExtensionComponent( + private val project: Project, + private val authenticationManager: GithubAuthenticationManager, + private val executorManager: GithubApiRequestExecutorManager, + private val apiExecutorFactory: GithubApiRequestExecutor.Factory, + private val accountInformationProvider: GithubAccountInformationProvider, + avatarLoader: CachingGithubUserAvatarLoader, + imageResizer: GithubImageResizer +) : VcsCloneDialogExtensionComponent() { + private val LOG = logger() + + private val progressManager: ProgressVisibilityManager + + // UI + private val wrapper: Wrapper = Wrapper() + private val repositoriesPanel: DialogPanel + private val repositoryList: GHRepositoryList + + private val accountsPanel: GHCloneDialogAccountsPanel = GHCloneDialogAccountsPanel( + object : GHLoginController { + override fun addAccount() = switchToLogin() + override fun reLogin(account: GithubAccount) = switchToLogin(account) + override fun logout(account: GithubAccount) = authenticationManager.removeAccount(account) + }, + authenticationManager, + executorManager, + avatarLoader, + imageResizer + ) + + private val searchField: SearchTextField = SearchTextField(false).apply { + textEditor.emptyText.appendText("Search or enter a GitHub repository URL") + } + private val directoryField = SelectChildTextFieldWithBrowseButton( + ClonePathProvider.defaultParentDirectoryPath(project, GitRememberedInputs.getInstance())).apply { + val fcd = FileChooserDescriptorFactory.createSingleFolderDescriptor() + fcd.isShowFileSystemRoots = true + fcd.isHideIgnored = false + addBrowseFolderListener(getString("clone.destination.directory.browser.title"), + getString("clone.destination.directory.browser.description"), + project, + fcd) + } + + // state + private val userDetailsByAccount = hashMapOf() + private val repositoriesByAccount = hashMapOf>() + private val errorsByAccount = hashMapOf() + private val originListModel = CollectionListModel() + private var inLoginState = false + private var selectedUrl by Delegates.observable(null) { _, _, _ -> onSelectedUrlChanged() } + + init { + val speedSearch = SpeedSearch() + + val filteringListModel = NameFilteringListModel(originListModel, + { it.stringToSearch }, + speedSearch::shouldBeShowing, + { speedSearch.filter ?: "" } + ).apply { + setFilter { item -> + item != null && when (item) { + is GHRepositoryListItem.Repo -> speedSearch.shouldBeShowing(item.repo.fullName) + else -> speedSearch.filter.isEmpty() + } + } + } + + searchField.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + speedSearch.updatePattern(searchField.text) + filteringListModel.refilter() + updateSelectedUrl() + } + }) + + repositoryList = GHRepositoryList(filteringListModel).apply { + addListSelectionListener { + if (it.valueIsAdjusting) return@addListSelectionListener + updateSelectedUrl() + } + } + + progressManager = object : ProgressVisibilityManager() { + override fun setProgressVisible(visible: Boolean) = repositoryList.setPaintBusy(visible) + + override fun getModalityState() = ModalityState.any() + } + + this.attachChild(progressManager) + + ApplicationManager.getApplication().messageBus.connect(this).apply { + subscribe(GithubAccountManager.ACCOUNT_REMOVED_TOPIC, object : AccountRemovedListener { + override fun accountRemoved(removedAccount: GithubAccount) { + removeAccount(removedAccount) + dialogStateListener.onListItemChanged() + } + }) + + subscribe(GithubAccountManager.ACCOUNT_TOKEN_CHANGED_TOPIC, object : AccountTokenChangedListener { + override fun tokenChanged(account: GithubAccount) { + if (repositoriesByAccount[account] != null) + return + dialogStateListener.onListItemChanged() + addAccount(account) + switchToRepositories() + } + }) + } + + repositoriesPanel = panel { + row { + cell(isFullWidth = true) { + searchField(pushX, growX) + JSeparator(JSeparator.VERTICAL)(growY) + accountsPanel() + } + } + row { + ScrollPaneFactory.createScrollPane(repositoryList)(push, grow) + } + row("Directory:") { + directoryField(growX, pushX) + } + } + repositoriesPanel.border = JBEmptyBorder(UIUtil.PANEL_REGULAR_INSETS) + + if (authenticationManager.hasAccounts()) { + switchToRepositories() + authenticationManager.getAccounts().forEach(this@GHCloneDialogExtensionComponent::addAccount) + } + else { + switchToLogin() + } + } + + private fun switchToLogin(account: GithubAccount? = null) { + val errorPanel = JPanel(VerticalLayout(10)) + val githubLoginPanel = buildGitHubLoginPanel(account, errorPanel) + val loginPanel = JBUI.Panels.simplePanel() + .addToTop(githubLoginPanel) + .addToCenter(errorPanel) + wrapper.setContent(loginPanel) + wrapper.repaint() + inLoginState = true + updateSelectedUrl() + } + + private fun switchToRepositories() { + wrapper.setContent(repositoriesPanel) + wrapper.repaint() + inLoginState = false + updateSelectedUrl() + } + + private fun addAccount(account: GithubAccount) { + repositoriesByAccount.remove(account) + accountsPanel.addAccount(account) + + try { + val executor = executorManager.getExecutor(account) + loadUserDetails(account, executor) + loadRepositories(account, executor) + } + catch (e: GithubMissingTokenException) { + errorsByAccount[account] = GHRepositoryListItem.Error(account, + "Missing access token", + "Log in", + Runnable { switchToLogin(account) }) + refillRepositories() + } + } + + private fun removeAccount(account: GithubAccount) { + repositoriesByAccount.remove(account) + accountsPanel.removeAccount(account) + refillRepositories() + if (!authenticationManager.hasAccounts()) switchToLogin() + } + + private fun loadUserDetails(account: GithubAccount, + executor: GithubApiRequestExecutor.WithTokenAuth) { + progressManager.run(object : Task.Backgroundable(project, "Not Visible") { + lateinit var user: GithubAuthenticatedUser + + override fun run(indicator: ProgressIndicator) { + user = accountInformationProvider.getInformation(executor, indicator, account) + } + + override fun onSuccess() { + userDetailsByAccount[account] = user + accountsPanel.updateUserDetails(account, user) + refillRepositories() + } + + override fun onThrowable(error: Throwable) { + LOG.error(error) + errorsByAccount[account] = GHRepositoryListItem.Error(account, + "Unable to load repositories", + "Retry", + Runnable { addAccount(account) }) + } + }) + } + + private fun loadRepositories(account: GithubAccount, + executor: GithubApiRequestExecutor.WithTokenAuth) { + repositoriesByAccount.remove(account) + errorsByAccount.remove(account) + + progressManager.run(object : Task.Backgroundable(project, "Not Visible") { + override fun run(indicator: ProgressIndicator) { + val repoPagesRequest = GithubApiRequests.CurrentUser.Repos.pages(account.server, + affiliation = Affiliation.combine(Affiliation.OWNER, + Affiliation.COLLABORATOR), + pagination = GithubRequestPagination.DEFAULT) + val pageItemsConsumer: (List) -> Unit = { + runInEdt { + repositoriesByAccount.getOrPut(account, { UpdateOrderLinkedHashSet() }).addAll(it) + refillRepositories() + } + } + GithubApiPagesLoader.loadAll(executor, indicator, repoPagesRequest, pageItemsConsumer) + + val orgsRequest = GithubApiRequests.CurrentUser.Orgs.pages(account.server) + val userOrganizations = GithubApiPagesLoader.loadAll(executor, indicator, orgsRequest).sortedBy { it.login } + + for (org in userOrganizations) { + val orgRepoRequest = GithubApiRequests.Organisations.Repos.pages(account.server, org.login, GithubRequestPagination.DEFAULT) + GithubApiPagesLoader.loadAll(executor, indicator, orgRepoRequest, pageItemsConsumer) + } + } + + override fun onThrowable(error: Throwable) { + LOG.error(error) + errorsByAccount[account] = GHRepositoryListItem.Error(account, + "Unable to load repositories", + "Retry", + Runnable { loadRepositories(account, executor) }) + } + }) + } + + private fun refillRepositories() { + val selectedValue = repositoryList.selectedValue + originListModel.removeAll() + for (account in authenticationManager.getAccounts()) { + if (errorsByAccount[account] != null) { + originListModel.add(errorsByAccount[account]) + } + val user = userDetailsByAccount[account] ?: continue + val repos = repositoriesByAccount[account] ?: continue + for (repo in repos) { + originListModel.add(GHRepositoryListItem.Repo(account, user, repo)) + } + } + repositoryList.setSelectedValue(selectedValue, false) + } + + override fun getView() = wrapper + + override fun doValidateAll(): List { + val list = ArrayList() + ContainerUtil.addIfNotNull(list, CloneDvcsValidationUtils.checkDirectory(directoryField.text, directoryField.textField)) + ContainerUtil.addIfNotNull(list, CloneDvcsValidationUtils.createDestination(directoryField.text)) + return list + } + + override fun doClone() { + val parent = Paths.get(directoryField.text).toAbsolutePath().parent + + val lfs = LocalFileSystem.getInstance() + var destinationParent = lfs.findFileByIoFile(parent.toFile()) + if (destinationParent == null) { + destinationParent = lfs.refreshAndFindFileByIoFile(parent.toFile()) + } + if (destinationParent == null) { + return + } + val directoryName = Paths.get(directoryField.text).fileName.toString() + val parentDirectory = parent.toAbsolutePath().toString() + + GitCheckoutProvider.clone(project, Git.getInstance(), ProjectLevelVcsManager.getInstance(project).compositeCheckoutListener, + destinationParent, selectedUrl, directoryName, parentDirectory) + } + + override fun onComponentSelected() { + dialogStateListener.onOkActionNameChanged("Clone") + updateSelectedUrl() + } + + private fun buildGitHubLoginPanel(account: GithubAccount?, + errorPanel: JPanel): GithubLoginPanel { + val alwaysUnique: (name: String, server: GithubServerPath) -> Boolean = { _, _ -> true } + return GithubLoginPanel( + apiExecutorFactory, + if (account == null) authenticationManager::isAccountUnique else alwaysUnique, + project, + false + ).apply { + border = JBEmptyBorder(UIUtil.PANEL_REGULAR_INSETS) + if (account != null) { + setCredentials(account.name, null, false) + setServer(account.server.toUrl(), false) + } + + setLoginListener(ActionListener { + acquireLoginAndToken(EmptyProgressIndicator(ModalityState.stateForComponent(this))) + .handleOnEdt { loginToken, throwable -> + errorPanel.removeAll() + if (throwable != null) { + for (validationInfo in doValidateAll()) { + val component = SimpleColoredComponent() + component.append(validationInfo.message, SimpleTextAttributes.ERROR_ATTRIBUTES) + errorPanel.add(component) + errorPanel.revalidate() + } + errorPanel.repaint() + } + if (loginToken != null) { + val login = loginToken.first + val token = loginToken.second + if (account != null) { + authenticationManager.updateAccountToken(account, token) + } + else { + authenticationManager.registerAccount(login, getServer().host, token) + } + } + } + }) + setCancelListener(ActionListener { switchToRepositories() }) + setLoginButtonVisible(true) + setCancelButtonVisible(authenticationManager.hasAccounts()) + } + } + + private fun updateSelectedUrl() { + repositoryList.emptyText.clear() + if (inLoginState) { + selectedUrl = null + return + } + val githubRepoPath = getGithubRepoPath(searchField.text) + if (githubRepoPath != null) { + selectedUrl = githubRepoPath.toUrl() + repositoryList.emptyText.appendText("Clone '$githubRepoPath'") + return + } + val selectedValue = repositoryList.selectedValue + if (selectedValue is GHRepositoryListItem.Repo) { + selectedUrl = selectedValue.repo.cloneUrl + return + } + selectedUrl = null + } + + private fun getGithubRepoPath(url: String): GithubRepositoryPath? { + try { + if (!url.endsWith(GitUtil.DOT_GIT, true)) return null + + var serverPath = GithubServerPath.from(url) + serverPath = GithubServerPath.from(serverPath.toUrl().removeSuffix(serverPath.suffix ?: "")) + + val githubFullPath = GithubUrlUtil.getUserAndRepositoryFromRemoteUrl(url) ?: return null + return GithubRepositoryPath(serverPath, githubFullPath) + } + catch (e: Throwable) { + return null + } + } + + private fun onSelectedUrlChanged() { + val urlSelected = selectedUrl != null + dialogStateListener.onOkActionEnabled(urlSelected) + directoryField.isEnabled = urlSelected + if (urlSelected) { + val path = StringUtil.trimEnd(ClonePathProvider.relativeDirectoryPathForVcsUrl(project, selectedUrl!!), GitUtil.DOT_GIT) + directoryField.trySetChildPath(path) + } + } + + /** + * Since each repository can be in several states at the same time (shared access for a collaborator and shared access for org member) and + * repositories for collaborators are loaded in separate request before repositories for org members, we need to update order of re-added + * repo in order to place it close to other organization repos + */ + private class UpdateOrderLinkedHashSet : LinkedHashSet() { + override fun add(element: T): Boolean { + val wasThere = remove(element) + super.add(element) + // Contract is "true if this set did not already contain the specified element" + return !wasThere + } + } +} diff --git a/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHLoginController.kt b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHLoginController.kt new file mode 100644 index 000000000000..6b46d29c9432 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHLoginController.kt @@ -0,0 +1,12 @@ +// 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.ui.cloneDialog + +import org.jetbrains.plugins.github.authentication.accounts.GithubAccount + +internal interface GHLoginController { + fun addAccount() + + fun reLogin(account: GithubAccount) + + fun logout(account: GithubAccount) +} \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHRepositoryList.kt b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHRepositoryList.kt new file mode 100644 index 000000000000..ad2727e6b4a5 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHRepositoryList.kt @@ -0,0 +1,114 @@ +// 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.ui.cloneDialog + +import com.intellij.ui.* +import com.intellij.ui.components.JBList +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import org.jetbrains.plugins.github.authentication.GithubAuthenticationManager +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Cursor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JList +import javax.swing.ListModel + +internal class GHRepositoryList(listModel: ListModel) : JBList(listModel) { + private val authenticationManager = GithubAuthenticationManager.getInstance() + + init { + ScrollingUtil.installActions(this) + + selectionModel = SingleSelectionModel() + + cellRenderer = object : ColoredListCellRenderer() { + val nameRenderer = AccountNameRenderer() + + override fun getListCellRendererComponent(list: JList, + value: GHRepositoryListItem, + index: Int, + selected: Boolean, + hasFocus: Boolean): Component { + val component = super.getListCellRendererComponent(list, value, index, selected, hasFocus) + if (showAccountNameAbove(list, index)) { + val name = with(value) { + if (account.server.isGithubDotCom) account.name else ("${account.server.host}/${account.name}") + } + return nameRenderer.withName(name, component, index != 0) + } + return component + } + + private fun showAccountNameAbove(list: JList, index: Int): Boolean { + return authenticationManager.getAccounts().size > 1 + && (index == 0 || list.model.getElementAt(index).account != list.model.getElementAt(index - 1).account) + } + + override fun customizeCellRenderer(list: JList, + value: GHRepositoryListItem, + index: Int, + selected: Boolean, + hasFocus: Boolean) = value.customizeRenderer(this, list) + } + + val mouseAdapter = createMouseAdapter() + addMouseListener(mouseAdapter) + addMouseMotionListener(mouseAdapter) + } + + private fun createMouseAdapter(): MouseAdapter { + return object : MouseAdapter() { + fun getRunnableAt(e: MouseEvent): Runnable? { + val point = e.point + val renderer = ListUtil.getDeepestRendererChildComponentAt(this@GHRepositoryList, point) + if (renderer !is SimpleColoredComponent) return null + val tag = renderer.getFragmentTagAt(point.x) + return if (tag is Runnable) tag else null + } + + override fun mouseMoved(e: MouseEvent) { + val runnable = getRunnableAt(e) + if (runnable != null) { + UIUtil.setCursor(this@GHRepositoryList, Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) + } + else { + UIUtil.setCursor(this@GHRepositoryList, Cursor.getDefaultCursor()) + } + } + + override fun mouseClicked(e: MouseEvent) { + getRunnableAt(e)?.run() + } + } + } + + private class AccountNameRenderer : CellRendererPanel() { + private val titleLabel = SimpleColoredComponent().apply { + background = UIUtil.getListBackground() + } + private val topLine = JBUI.Borders.customLine(JBUI.CurrentTheme.BigPopup.listSeparatorColor(), 1, 0, 0, 0) + private val borderLayout = BorderLayout() + + init { + layout = borderLayout + add(titleLabel, BorderLayout.NORTH) + background = UIUtil.getListBackground() + } + + fun withName(title: String, itemContent: Component, withBorder: Boolean): AccountNameRenderer { + titleLabel.border = null + titleLabel.clear() + titleLabel.append(title, SimpleTextAttributes.GRAY_SMALL_ATTRIBUTES) + if (withBorder) { + titleLabel.border = topLine + } + val prevContent = borderLayout.getLayoutComponent(BorderLayout.CENTER) + if (prevContent != null) { + remove(prevContent) + } + add(itemContent, BorderLayout.CENTER) + return this + } + } +} \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHRepositoryListItem.kt b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHRepositoryListItem.kt new file mode 100644 index 000000000000..5a587ba57a65 --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/ui/cloneDialog/GHRepositoryListItem.kt @@ -0,0 +1,110 @@ +// 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.ui.cloneDialog + +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.SimpleTextAttributes +import org.jetbrains.plugins.github.api.data.GithubRepo +import org.jetbrains.plugins.github.api.data.GithubUser +import org.jetbrains.plugins.github.authentication.accounts.GithubAccount +import javax.swing.JList + +internal sealed class GHRepositoryListItem( + val account: GithubAccount +) { + open val stringToSearch: String? + get() = null + + abstract fun customizeRenderer(renderer: ColoredListCellRenderer, + list: JList) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GHRepositoryListItem + + if (account != other.account) return false + + return true + } + + override fun hashCode(): Int { + return account.hashCode() + } + + class Repo( + account: GithubAccount, + val user: GithubUser, + val repo: GithubRepo + ) : GHRepositoryListItem(account) { + override val stringToSearch get() = repo.fullName + + override fun customizeRenderer(renderer: ColoredListCellRenderer, + list: JList): Unit = + with(renderer) { + ipad.left = 10 + toolTipText = repo.description + append(if (repo.owner.login == user.login) repo.name else repo.fullName) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + + other as Repo + + if (user != other.user) return false + if (repo != other.repo) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + user.hashCode() + result = 31 * result + repo.hashCode() + return result + } + } + + class Error( + account: GithubAccount, + private val errorText: String, + private val linkText: String, + private val linkHandler: Runnable + ) : GHRepositoryListItem(account) { + + override fun customizeRenderer(renderer: ColoredListCellRenderer, + list: JList) = + with(renderer) { + ipad.left = 10 + toolTipText = null + append(errorText, SimpleTextAttributes.ERROR_ATTRIBUTES) + append(" ") + append(linkText, SimpleTextAttributes.LINK_ATTRIBUTES, linkHandler) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + + other as Error + + if (errorText != other.errorText) return false + if (linkText != other.linkText) return false + if (linkHandler != other.linkHandler) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + errorText.hashCode() + result = 31 * result + linkText.hashCode() + result = 31 * result + linkHandler.hashCode() + return result + } + } +} \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/util/CachingGithubUserAvatarLoader.kt b/plugins/github/src/org/jetbrains/plugins/github/util/CachingGithubUserAvatarLoader.kt index f8de46588fcc..5bfe57a1f274 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/util/CachingGithubUserAvatarLoader.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/util/CachingGithubUserAvatarLoader.kt @@ -3,6 +3,7 @@ package org.jetbrains.plugins.github.util import com.google.common.cache.CacheBuilder import com.intellij.openapi.Disposable +import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.openapi.progress.ProcessCanceledException @@ -70,6 +71,11 @@ class CachingGithubUserAvatarLoader(private val progressManager: ProgressManager } companion object { + @JvmStatic + fun getInstance(): CachingGithubUserAvatarLoader { + return service() + } + private const val MAXIMUM_ICON_SIZE = 40 } } \ No newline at end of file diff --git a/plugins/github/src/org/jetbrains/plugins/github/util/GithubImageResizer.kt b/plugins/github/src/org/jetbrains/plugins/github/util/GithubImageResizer.kt index bc4023a2af40..4a15ecec6879 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/util/GithubImageResizer.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/util/GithubImageResizer.kt @@ -2,6 +2,7 @@ package org.jetbrains.plugins.github.util import com.intellij.openapi.Disposable +import com.intellij.openapi.components.service import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.util.Computable @@ -38,5 +39,10 @@ class GithubImageResizer(private val progressManager: ProgressManager) : Disposa companion object { private fun getThreadPoolSize() = Math.max(Runtime.getRuntime().availableProcessors() / 2, 1) + + @JvmStatic + fun getInstance(): GithubImageResizer { + return service() + } } } diff --git a/plugins/github/test/org/jetbrains/plugins/github/GithubRequestQueryingTest.kt b/plugins/github/test/org/jetbrains/plugins/github/GithubRequestQueryingTest.kt index 7783830f96be..7d8af50174bd 100644 --- a/plugins/github/test/org/jetbrains/plugins/github/GithubRequestQueryingTest.kt +++ b/plugins/github/test/org/jetbrains/plugins/github/GithubRequestQueryingTest.kt @@ -5,6 +5,7 @@ import com.intellij.openapi.progress.EmptyProgressIndicator import org.jetbrains.plugins.github.api.GithubApiRequests import org.jetbrains.plugins.github.api.data.GithubRepo import org.jetbrains.plugins.github.api.data.request.GithubRequestPagination +import org.jetbrains.plugins.github.api.data.request.Type import org.jetbrains.plugins.github.api.util.GithubApiPagesLoader import org.jetbrains.plugins.github.test.GithubTest @@ -39,8 +40,9 @@ class GithubRequestQueryingTest : GithubTest() { retry { val result = GithubApiPagesLoader .loadAll(mainAccount.executor, EmptyProgressIndicator(), - GithubApiRequests.CurrentUser.Repos.pages(mainAccount.account.server, false, - GithubRequestPagination(pageSize = 2))) + GithubApiRequests.CurrentUser.Repos.pages(mainAccount.account.server, + type = Type.OWNER, + pagination = GithubRequestPagination(pageSize = 2))) assertContainsElements(result, mainRepos) assertDoesntContain(result, secondaryRepos) @@ -52,7 +54,8 @@ class GithubRequestQueryingTest : GithubTest() { retry { val result = GithubApiPagesLoader .loadAll(mainAccount.executor, EmptyProgressIndicator(), - GithubApiRequests.CurrentUser.Repos.pages(mainAccount.account.server, false)) + GithubApiRequests.CurrentUser.Repos.pages(mainAccount.account.server, + type = Type.OWNER)) assertContainsElements(result, mainRepos) assertDoesntContain(result, orgRepo)