[github] Multiple accounts configuration

This commit is contained in:
Ivan Semenov
2018-04-17 15:14:39 +03:00
parent 52453f69b3
commit f1ffc61ba5
14 changed files with 908 additions and 255 deletions
@@ -111,6 +111,10 @@ public class CollectionListModel<T> extends AbstractListModel<T> implements Edit
fireContentsChanged(this, i, i);
}
public void allContentsChanged() {
fireContentsChanged(this, 0, myItems.size() - 1);
}
public void sort(final Comparator<T> comparator) {
Collections.sort(myItems, comparator);
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="40px" height="40px" viewBox="0 0 40 40" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
<title>github/resources/org/jetbrains/plugins/github/GitHubAvatar_40</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="github/resources/org/jetbrains/plugins/github/GitHubAvatar_40" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity="0.5">
<g id="avatar" transform="translate(6.000000, 6.000000)" fill="#7F8B91" fill-rule="nonzero">
<path d="M14,14 C17.6833333,14 20.6666667,11.0166667 20.6666667,7.33333333 C20.6666667,3.65 17.6833333,0.666666667 14,0.666666667 C10.3166667,0.666666667 7.33333333,3.65 7.33333333,7.33333333 C7.33333333,11.0166667 10.3166667,14 14,14 Z M14,17.3333333 C9.55,17.3333333 0.666666667,19.5666667 0.666666667,24 L0.666666667,27.3333333 L27.3333333,27.3333333 L27.3333333,24 C27.3333333,19.5666667 18.45,17.3333333 14,17.3333333 Z" id="Shape"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -1
View File
@@ -24,11 +24,12 @@
<applicationService serviceImplementation="org.jetbrains.plugins.github.authentication.accounts.GithubAccountManager"/>
<applicationService serviceImplementation="org.jetbrains.plugins.github.authentication.GithubAuthenticationManager"/>
<applicationService serviceImplementation="org.jetbrains.plugins.github.api.GithubApiTaskExecutor"/>
<applicationService serviceImplementation="org.jetbrains.plugins.github.authentication.accounts.GithubAccountInformationProvider"/>
<projectService serviceInterface="org.jetbrains.plugins.github.util.GithubProjectSettings"
serviceImplementation="org.jetbrains.plugins.github.util.GithubProjectSettings"/>
<projectService serviceImplementation="org.jetbrains.plugins.github.authentication.accounts.GithubProjectDefaultAccountHolder"/>
<vcsAnnotationGutterActionProvider implementation="org.jetbrains.plugins.github.extensions.GithubAnnotationGutterActionProvider"/>
<vcsConfigurableProvider implementation="org.jetbrains.plugins.github.ui.GithubSettingsConfigurable"/>
<projectConfigurable parentId="project.propVCSSupport.Mappings" instance="org.jetbrains.plugins.github.ui.GithubSettingsConfigurable"/>
</extensions>
<extensions defaultExtensionNs="Git4Idea">
@@ -28,5 +28,6 @@ public class GithubIcons {
return IconLoader.getIcon(path, GithubIcons.class);
}
public static final Icon DefaultAvatar_40 = load("/org/jetbrains/plugins/github/defaultAvatar_40.svg"); // 40x40
public static final Icon Github_icon = load("/org/jetbrains/plugins/github/github_icon.png"); // 16x16
}
@@ -7,6 +7,11 @@ import com.intellij.util.xmlb.annotations.Tag;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.plugins.github.exceptions.GithubParseException;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.Locale;
@@ -51,8 +56,54 @@ public class GithubServerPath {
return mySuffix;
}
// 2 - host, 4 - port, 5 - path
private final static Pattern URL_REGEX = Pattern.compile("^(https?://)?([^/?:]+)(:(\\d+))?((/[^/?#]+)*)?");
@NotNull
public static GithubServerPath from(@NotNull String uri) throws GithubParseException {
Matcher matcher = URL_REGEX.matcher(uri);
if (!matcher.matches()) throw new GithubParseException("Not a valid URL");
String host = matcher.group(2);
if (host == null) throw new GithubParseException("Empty host");
Integer port;
String portGroup = matcher.group(4);
if (portGroup == null) {
port = null;
}
else {
try {
port = Integer.parseInt(portGroup);
}
catch (NumberFormatException e) {
throw new GithubParseException("Invalid port format");
}
}
String path = StringUtil.nullize(matcher.group(5));
return new GithubServerPath(host, port, path);
}
public String toString() {
String port = myPort != null ? (":" + myPort.toString()) : "";
return myHost + port + StringUtil.notNullize(mySuffix);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof GithubServerPath)) return false;
GithubServerPath path = (GithubServerPath)o;
return Objects.equals(myHost, path.myHost) &&
Objects.equals(myPort, path.myPort) &&
Objects.equals(mySuffix, path.mySuffix);
}
@Override
public int hashCode() {
return Objects.hash(myHost, myPort, mySuffix);
}
}
@@ -0,0 +1,34 @@
// 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.authentication.accounts
import com.intellij.openapi.progress.ProgressIndicator
import org.jetbrains.plugins.github.api.GithubApiTaskExecutor
import org.jetbrains.plugins.github.api.GithubTask
import org.jetbrains.plugins.github.api.GithubApiUtil
import org.jetbrains.plugins.github.api.GithubServerPath
import org.jetbrains.plugins.github.api.data.GithubUserDetailed
import java.awt.Image
import java.io.IOException
import java.net.URL
import javax.imageio.ImageIO
//TODO: caching
//TODO: load image with GithubApiTaskExecutor
class GithubAccountInformationProvider(private val apiTaskExecutor: GithubApiTaskExecutor) {
@Throws(IOException::class)
fun getAccountInformationWithPicture(indicator: ProgressIndicator, server: GithubServerPath, token: String): Pair<GithubUserDetailed, Image> {
val details = GithubApiTaskExecutor.execute(indicator, server, token, GithubTask { c -> GithubApiUtil.getCurrentUser(c) })
return details to ImageIO.read(URL(details.avatarUrl))
}
@Throws(IOException::class)
fun getAccountInformationWithPicture(indicator: ProgressIndicator, account: GithubAccount): Pair<GithubUserDetailed, Image> {
val details = getAccountInformation(indicator, account)
return details to ImageIO.read(URL(details.avatarUrl))
}
@Throws(IOException::class)
fun getAccountInformation(indicator: ProgressIndicator, account: GithubAccount): GithubUserDetailed {
return apiTaskExecutor.execute(indicator, account, GithubTask { c -> GithubApiUtil.getCurrentUser(c) })
}
}
@@ -0,0 +1,386 @@
// 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.authentication.ui
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonShortcuts
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.Task
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.*
import com.intellij.ui.SimpleTextAttributes.STYLE_PLAIN
import com.intellij.ui.SimpleTextAttributes.STYLE_UNDERLINE
import com.intellij.ui.components.JBList
import com.intellij.util.Alarm
import com.intellij.util.AlarmFactory
import com.intellij.util.progress.ProgressVisibilityManager
import com.intellij.util.ui.*
import com.intellij.util.ui.components.BorderLayoutPanel
import icons.GithubIcons
import org.jetbrains.plugins.github.api.GithubServerPath
import org.jetbrains.plugins.github.api.data.GithubUserDetailed
import org.jetbrains.plugins.github.authentication.accounts.GithubAccount
import org.jetbrains.plugins.github.authentication.accounts.GithubAccountInformationProvider
import org.jetbrains.plugins.github.exceptions.GithubAuthenticationException
import java.awt.*
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.*
import javax.swing.event.ListDataEvent
import javax.swing.event.ListDataListener
private const val ACCOUNT_PICTURE_SIZE: Int = 40
internal class GithubAccountsPanel(private val project: Project, private val accountInformationProvider: GithubAccountInformationProvider)
: BorderLayoutPanel(), Disposable {
private val accountListModel = CollectionListModel<GithubAccountDecorator>().apply {
// disable link handler when there are no errors
addListDataListener(object : ListDataListener {
override fun contentsChanged(e: ListDataEvent?) = setLinkHandlerEnabled(items.any { it.loadingError != null })
override fun intervalRemoved(e: ListDataEvent?) {}
override fun intervalAdded(e: ListDataEvent?) {}
})
}
private val accountList = JBList<GithubAccountDecorator>(accountListModel).apply {
cellRenderer = GithubAccountDecoratorRenderer()
selectionMode = ListSelectionModel.SINGLE_SELECTION
selectionForeground = UIUtil.getListForeground()
selectionBackground = JBColor(0xE9EEF5, 0x464A4D)
emptyText.apply {
appendText("No GitHub accounts added.")
appendSecondaryText("Add account", SimpleTextAttributes.LINK_ATTRIBUTES, { addAccount() })
appendSecondaryText(" (${KeymapUtil.getFirstKeyboardShortcutText(CommonShortcuts.getNew())})", StatusText.DEFAULT_ATTRIBUTES, null)
}
}
private val progressManager = createListProgressManager()
private val errorLinkHandler = createLinkActivationListener()
private var errorLinkHandlerInstalled = false
private val newTokensMap = mutableMapOf<GithubAccount, String>()
init {
addToCenter(ToolbarDecorator.createDecorator(accountList)
.disableUpDownActions()
.setPanelBorder(IdeBorderFactory.createBorder(SideBorder.TOP or SideBorder.BOTTOM))
.setAddAction { addAccount() }
.addExtraAction(object : ToolbarDecorator.ElementActionButton("Set default",
AllIcons.Actions.Checked) {
override fun actionPerformed(e: AnActionEvent?) {
if (accountList.selectedValue.projectDefault) return
for (accountData in accountListModel.items) {
if (accountData == accountList.selectedValue) {
accountData.projectDefault = true
accountListModel.contentsChanged(accountData)
}
else if (accountData.projectDefault) {
accountData.projectDefault = false
accountListModel.contentsChanged(accountData)
}
}
}
override fun updateButton(e: AnActionEvent?) {
isEnabled = isEnabled && !accountList.selectedValue.projectDefault
}
})
.createPanel())
Disposer.register(this, progressManager)
Disposer.register(this, errorLinkHandler)
}
private fun addAccount() {
val dialog = GithubLoginDialog(project,
::isAccountUnique,
this)
if (dialog.showAndGet()) {
val githubAccount = GithubAccount(dialog.getLogin(), dialog.getServer())
newTokensMap[githubAccount] = dialog.getToken()
val accountData = GithubAccountDecorator(githubAccount, false)
accountListModel.add(accountData)
loadAccountDetails(accountData)
}
}
private fun editAccount(decorator: GithubAccountDecorator) {
val dialog = GithubLoginDialog(project,
{ _, _ -> true },
this,
host = decorator.account.server.toString(),
editableHost = false)
if (dialog.showAndGet()) {
decorator.account.name = dialog.getLogin()
newTokensMap[decorator.account] = dialog.getToken()
loadAccountDetails(decorator)
}
}
private fun isAccountUnique(login: String, server: GithubServerPath) =
accountListModel.items.none { it.account.name == login && it.account.server == server }
/**
* Manages link hover and click for [GithubAccountDecoratorRenderer.loadingError]
* Sets the proper cursor and underlines the link on hover
* Mouse movement handling is performed with delay to reduce performance impact
*
* @see [GithubAccountDecorator.loadingError]
* @see [GithubAccountDecorator.showLoginLink]
* @see [GithubAccountDecorator.errorLinkPointedAt]
*/
private fun createLinkActivationListener() = object : MouseAdapter(), Disposable {
// Display refresh delay at 25 fps
private val DELAY_MS: Long = 40L
private var linkStylingAlarm = AlarmFactory.getInstance()
.create(Alarm.ThreadToUse.SWING_THREAD, this)
.apply { setActivationComponent(accountList) }
override fun mouseMoved(e: MouseEvent) {
linkStylingAlarm.cancelAllRequests()
linkStylingAlarm.addComponentRequest(
{
for (item in accountListModel.items) {
item.errorLinkPointedAt = false
}
val (decorator, linkPointedAt) = findDecoratorWithLoginLinkAt(e.point)
if (decorator != null) {
UIUtil.setCursor(accountList,
if (linkPointedAt) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor())
decorator.errorLinkPointedAt = linkPointedAt
}
else {
UIUtil.setCursor(accountList, Cursor.getDefaultCursor())
}
accountListModel.allContentsChanged()
}, DELAY_MS)
}
override fun mouseClicked(e: MouseEvent) {
val (decorator, linkPointedAt) = findDecoratorWithLoginLinkAt(e.point)
if (decorator != null && linkPointedAt) editAccount(decorator)
}
/**
* Finds component under mouse pointer and if it's a decorator - checks if pointer is on the error label link
*/
private fun findDecoratorWithLoginLinkAt(point: Point): Pair<GithubAccountDecorator?, Boolean> {
val idx = accountList.locationToIndex(point)
if (idx < 0) return null to false
val cellBounds = accountList.getCellBounds(idx, idx)
if (!cellBounds.contains(point)) return null to false
val decorator = accountListModel.getElementAt(idx)
if (decorator?.loadingError == null) return null to false
val rendererComponent = accountList.cellRenderer.getListCellRendererComponent(accountList, decorator, idx, true, true)
rendererComponent.setLocation(cellBounds.x, cellBounds.y)
rendererComponent.setSize(cellBounds.width, cellBounds.height)
val rendererRelativePoint = Point(point.x - cellBounds.x, point.y - cellBounds.y)
val childComponent = UIUtil.getDeepestComponentAt(rendererComponent, rendererRelativePoint.x, rendererRelativePoint.y)
if (childComponent == null || childComponent !is SimpleColoredComponent) return decorator to false
val childRelativeX = SwingUtilities.convertPoint(rendererComponent, rendererRelativePoint, childComponent).x
return if (childComponent.findFragmentAt(childRelativeX) == 2) decorator to true else decorator to false
}
override fun dispose() {}
}
private fun setLinkHandlerEnabled(enabled: Boolean) {
if (enabled) {
if (!errorLinkHandlerInstalled) {
accountList.addMouseListener(errorLinkHandler)
accountList.addMouseMotionListener(errorLinkHandler)
errorLinkHandlerInstalled = true
}
}
else if (errorLinkHandlerInstalled) {
accountList.removeMouseListener(errorLinkHandler)
accountList.removeMouseMotionListener(errorLinkHandler)
errorLinkHandlerInstalled = false
}
}
fun loadExistingAccountsDetails() {
for (accountData in accountListModel.items) {
loadAccountDetails(accountData)
}
}
private fun loadAccountDetails(accountData: GithubAccountDecorator) {
val newToken: String? = newTokensMap[accountData.account]
progressManager.run(object : Task.Backgroundable(project, "Not Visible") {
lateinit var data: Pair<GithubUserDetailed, Image>
override fun run(indicator: ProgressIndicator) {
data = if (newToken != null) {
accountInformationProvider.getAccountInformationWithPicture(indicator, accountData.account.server, newToken)
}
else {
accountInformationProvider.getAccountInformationWithPicture(indicator, accountData.account)
}
}
override fun onSuccess() {
accountListModel.contentsChanged(accountData.apply {
fullName = data.first.name
profilePicture = data.second
loadingError = null
})
}
override fun onThrowable(error: Throwable) {
accountListModel.contentsChanged(accountData.apply {
loadingError = error.message.toString()
showLoginLink = error is GithubAuthenticationException
})
}
})
}
private fun createListProgressManager() = object : ProgressVisibilityManager() {
override fun setProgressVisible(visible: Boolean) = accountList.setPaintBusy(visible)
override fun getModalityState() = ModalityState.any()
}
fun setAccounts(accounts: Set<GithubAccount>, defaultAccount: GithubAccount?) {
accountListModel.removeAll()
accountListModel.addAll(0, accounts.map { GithubAccountDecorator(it, it == defaultAccount) })
}
/**
* @return list of accounts and associated tokens if new token was created and selected default account
*/
fun getAccounts(): Pair<Map<GithubAccount, String?>, GithubAccount?> {
return accountListModel.items.associate { it.account to newTokensMap[it.account] } to
accountListModel.items.find { it.projectDefault }?.account
}
fun clearNewTokens() = newTokensMap.clear()
fun isModified(accounts: Set<GithubAccount>, defaultAccount: GithubAccount?): Boolean {
return accountListModel.items.find { it.projectDefault }?.account != defaultAccount ||
accountListModel.items.map { it.account }.toSet() != accounts ||
newTokensMap.isNotEmpty()
}
override fun dispose() {}
}
private class GithubAccountDecoratorRenderer : ListCellRenderer<GithubAccountDecorator>, JPanel() {
private val accountName = JLabel()
private val serverName = JLabel()
private val profilePicture = JLabel()
private val fullName = JLabel()
private val loadingError = SimpleColoredComponent()
init {
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
border = JBUI.Borders.empty(5, 8)
val namesPanel = JPanel().apply {
layout = GridBagLayout()
border = JBUI.Borders.empty(0, 6, 4, 6)
val bag = GridBag()
.setDefaultInsets(JBUI.insetsRight(UIUtil.DEFAULT_HGAP))
.setDefaultAnchor(GridBagConstraints.WEST)
.setDefaultFill(GridBagConstraints.VERTICAL)
add(fullName, bag.nextLine().next())
add(accountName, bag.next())
add(loadingError, bag.next())
add(serverName, bag.nextLine().coverLine())
}
add(profilePicture)
add(namesPanel)
}
override fun getListCellRendererComponent(list: JList<out GithubAccountDecorator>,
value: GithubAccountDecorator,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean): Component {
UIUtil.setBackgroundRecursively(this, if (isSelected) list.selectionBackground else list.background)
val textColor = if (isSelected) list.selectionForeground else list.foreground
val grayTextColor = if (isSelected) list.selectionForeground else Gray._120
accountName.apply {
text = value.account.name
setBold(if (value.fullName == null) value.projectDefault else false)
foreground = if (value.fullName == null) textColor else grayTextColor
}
serverName.apply {
text = value.account.server.toString()
foreground = grayTextColor
}
profilePicture.apply {
icon = value.profilePicture?.let {
val size = JBUI.scale(ACCOUNT_PICTURE_SIZE)
JBImageIcon(it.getScaledInstance(size, size, java.awt.Image.SCALE_FAST))
} ?: GithubIcons.DefaultAvatar_40
}
fullName.apply {
text = value.fullName
setBold(value.projectDefault)
isVisible = value.fullName != null
foreground = textColor
}
loadingError.apply {
clear()
value.loadingError?.let {
append(it, SimpleTextAttributes.ERROR_ATTRIBUTES)
append(" ")
if (value.showLoginLink) append("Log In",
if (value.errorLinkPointedAt) SimpleTextAttributes(STYLE_UNDERLINE, JBColor.link())
else SimpleTextAttributes(STYLE_PLAIN, JBColor.link()))
}
}
return this
}
companion object {
private fun JLabel.setBold(isBold: Boolean) {
font = font.deriveFont(if (isBold) font.style or Font.BOLD else font.style and Font.BOLD.inv())
}
}
}
/**
* Account + auxillary info + info loading error
*/
private class GithubAccountDecorator(val account: GithubAccount, var projectDefault: Boolean) {
var fullName: String? = null
var profilePicture: Image? = null
var loadingError: String? = null
var showLoginLink = false
var errorLinkPointedAt = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as GithubAccountDecorator
if (account != other.account) return false
return true
}
override fun hashCode(): Int {
return account.hashCode()
}
}
@@ -0,0 +1,283 @@
// 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.authentication.ui
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
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.DialogWrapper
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.openapi.util.Disposer
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBPasswordField
import com.intellij.ui.components.JBTextField
import com.intellij.ui.components.fields.ExtendableTextField
import com.intellij.ui.components.labels.LinkLabel
import com.intellij.ui.components.panels.Wrapper
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UI.PanelFactory.grid
import com.intellij.util.ui.UI.PanelFactory.panel
import com.intellij.util.ui.UIUtil
import org.jetbrains.plugins.github.api.GithubApiTaskExecutor
import org.jetbrains.plugins.github.api.GithubApiUtil
import org.jetbrains.plugins.github.api.GithubServerPath
import org.jetbrains.plugins.github.api.GithubTask
import org.jetbrains.plugins.github.exceptions.GithubAuthenticationException
import org.jetbrains.plugins.github.exceptions.GithubParseException
import org.jetbrains.plugins.github.ui.util.DialogValidationUtils.chain
import org.jetbrains.plugins.github.ui.util.DialogValidationUtils.notBlank
import org.jetbrains.plugins.github.ui.util.Validator
import org.jetbrains.plugins.github.util.GithubUtil
import java.awt.Cursor
import java.net.UnknownHostException
import javax.swing.JComponent
import javax.swing.JEditorPane
import javax.swing.JPanel
import javax.swing.JTextField
import javax.swing.event.HyperlinkEvent
import javax.swing.text.html.HTMLEditorKit
class GithubLoginDialog(private val project: Project,
private val isAccountUnique: (name: String, server: GithubServerPath) -> Boolean,
parent: JComponent? = null,
title: String = "Log In to Github",
host: String = GithubApiUtil.DEFAULT_GITHUB_HOST,
editableHost: Boolean = true,
login: String = "",
private val tokenNote: String = GithubUtil.DEFAULT_TOKEN_NOTE)
: DialogWrapper(project, parent, false, IdeModalityType.PROJECT) {
private val serverTextField = ExtendableTextField(host).apply { isEditable = editableHost }
private var centerPanel = Wrapper()
private var southAdditionalPanel = Wrapper().apply { JBUI.Borders.emptyRight(UIUtil.DEFAULT_HGAP) }
private var passwordUi = LoginPasswordCredentialsUI(login)
private var tokenUi = TokenCredentialsUI()
private lateinit var currentUi: CredentialsUI
private var progressIndicator: ProgressIndicator? = null
private val progressIcon = AnimatedIcon.Default()
private val progressExtension = ExtendableTextField.Extension({ progressIcon })
private lateinit var login: String
private lateinit var token: String
private var tokenAcquisitionError: ValidationInfo? = null
init {
this.title = title
setOKButtonText("Log In")
applyUi(passwordUi)
init()
Disposer.register(disposable, Disposable { progressIndicator?.cancel() })
}
fun getServer(): GithubServerPath = GithubServerPath.from(serverTextField.text)
fun getLogin(): String = login
fun getToken(): String = token
override fun doOKAction() {
setBusy(true)
tokenAcquisitionError = null
service<ProgressManager>().runProcessWithProgressAsynchronously(object : Task.Backgroundable(project, "Not Visible") {
override fun run(indicator: ProgressIndicator) {
val (newLogin, newToken) = currentUi.acquireLoginAndToken(indicator)
login = newLogin
token = newToken
}
override fun onSuccess() {
close(OK_EXIT_CODE, true)
setBusy(false)
}
override fun onThrowable(error: Throwable) {
startTrackingValidation()
tokenAcquisitionError = currentUi.handleAcquireError(error)
setBusy(false)
}
}, progressIndicator!!)
}
private fun setBusy(busy: Boolean) {
if (busy) {
if (!serverTextField.extensions.contains(progressExtension)) serverTextField.addExtension(progressExtension)
progressIndicator?.cancel()
progressIndicator = EmptyProgressIndicator(ModalityState.stateForComponent(window))
Disposer.register(disposable, Disposable { progressIndicator?.cancel() })
}
else {
serverTextField.removeExtension(progressExtension)
progressIndicator = null
}
serverTextField.isEnabled = !busy
currentUi.setBusy(busy)
}
override fun createSouthAdditionalPanel() = southAdditionalPanel
override fun createCenterPanel() = centerPanel
override fun getPreferredFocusedComponent() = currentUi.getPreferredFocus()
private fun applyUi(ui: CredentialsUI) {
currentUi = ui
centerPanel.setContent(currentUi.getPanel())
southAdditionalPanel.setContent(currentUi.getSouthPanel())
setErrorText(null)
currentUi.getPreferredFocus().requestFocus()
tokenAcquisitionError = null
}
override fun doValidateAll(): List<ValidationInfo> {
return listOf(chain(chain({ notBlank(serverTextField, "Server cannot be empty") },
serverPathValidator(serverTextField)),
currentUi.getValidator()),
{ tokenAcquisitionError })
.mapNotNull { it() }
}
private fun serverPathValidator(textField: JTextField): Validator {
return {
val text = textField.text
try {
GithubServerPath.from(text)
null
}
catch (e: Exception) {
ValidationInfo("$text is not a valid server path:\n${e.message}", textField)
}
}
}
private inner class LoginPasswordCredentialsUI(login: String) : CredentialsUI() {
private val loginTextField = JBTextField(login)
private val passwordField = JBPasswordField()
private val contextHelp = JEditorPane()
init {
contextHelp.apply {
editorKit = UIUtil.getHTMLEditorKit()
val linkColor = JBColor.link()
//language=CSS
(editorKit as HTMLEditorKit).styleSheet.addRule("a {color: rgb(${linkColor.red}, ${linkColor.green}, ${linkColor.blue})}")
//language=HTML
text = "<html>Login and Password are not saved and used only to <br>acquire Github token. <a href=''>Enter token</a></html>"
addHyperlinkListener { e ->
if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) {
applyUi(tokenUi)
}
else {
cursor = if (e.eventType == HyperlinkEvent.EventType.ENTERED) Cursor(Cursor.HAND_CURSOR)
else Cursor(Cursor.DEFAULT_CURSOR)
}
}
isEditable = false
isFocusable = false
isOpaque = false
border = null
margin = JBUI.emptyInsets()
foreground = JBColor.GRAY
}
}
override fun getPanel() = grid()
.add(panel(serverTextField).withLabel("Server:"))
.add(panel(loginTextField).withLabel("Login:"))
.add(panel(passwordField).withLabel("Password:"))
.add(panel(contextHelp)).createPanel()
override fun getPreferredFocus() = loginTextField
override fun getValidator() = chain({ notBlank(loginTextField, "Login cannot be empty") },
{ notBlank(passwordField, "Password cannot be empty") })
override fun getSouthPanel() = JBUI.Panels.simplePanel()
.addToCenter(LinkLabel.create("Sign up for Github", Runnable { BrowserUtil.browse("https://github.com") }))
.addToRight(JBLabel(AllIcons.Ide.External_link_arrow))
override fun acquireLoginAndToken(indicator: ProgressIndicator): Pair<String, String> {
val server = getServer()
if (!isAccountUnique(loginTextField.text, server)) throw LoginNotUniqueException()
val token = GithubApiTaskExecutor.execute(indicator, server, loginTextField.text, passwordField.password,
GithubTask { GithubApiUtil.getMasterToken(it, tokenNote) })
return loginTextField.text to token
}
override fun handleAcquireError(error: Throwable): ValidationInfo {
return when (error) {
is LoginNotUniqueException -> ValidationInfo("Account already added", loginTextField)
is UnknownHostException -> ValidationInfo("Server is unreachable", false)
is GithubAuthenticationException -> ValidationInfo("Incorrect credentials.", false)
is GithubParseException -> ValidationInfo(error.message ?: "Invalid server path", serverTextField)
else -> ValidationInfo("Invalid authentication data.\n ${error.message}", false)
}
}
override fun setBusy(busy: Boolean) {
loginTextField.isEnabled = !busy
passwordField.isEnabled = !busy
contextHelp.isEnabled = !busy
}
}
private inner class TokenCredentialsUI : CredentialsUI() {
private val tokenTextField = JBTextField()
private val switchUiLink = LinkLabel.create("Log In with Username", { applyUi(passwordUi) })
override fun getPanel() = grid()
.add(panel(serverTextField).withLabel("Server:"))
.add(panel(tokenTextField).withLabel("Token:")).createPanel()
override fun getPreferredFocus() = tokenTextField
override fun getValidator() = { notBlank(tokenTextField, "Token cannot be empty") }
override fun getSouthPanel() = switchUiLink
override fun acquireLoginAndToken(indicator: ProgressIndicator): Pair<String, String> {
val login = GithubApiTaskExecutor.execute(indicator, getServer(), tokenTextField.text,
GithubTask { GithubApiUtil.getCurrentUser(it).login })
if (!isAccountUnique(login, getServer())) throw LoginNotUniqueException(login)
return login to tokenTextField.text
}
override fun handleAcquireError(error: Throwable): ValidationInfo {
return when (error) {
is LoginNotUniqueException -> ValidationInfo("Account ${error.login} already added", false)
is UnknownHostException -> ValidationInfo("Server is unreachable", false)
is GithubAuthenticationException -> ValidationInfo("Incorrect credentials.", false)
is GithubParseException -> ValidationInfo(error.message ?: "Invalid server path", serverTextField)
else -> ValidationInfo("Invalid authentication data.\n ${error.message}", false)
}
}
override fun setBusy(busy: Boolean) {
tokenTextField.isEnabled = !busy
switchUiLink.isEnabled = !busy
}
}
private abstract inner class CredentialsUI {
abstract fun getPanel(): JPanel
abstract fun getPreferredFocus(): JComponent
abstract fun getSouthPanel(): JComponent
abstract fun getValidator(): Validator
abstract fun acquireLoginAndToken(indicator: ProgressIndicator): Pair<String, String>
abstract fun handleAcquireError(error: Throwable): ValidationInfo
abstract fun setBusy(busy: Boolean)
}
private class LoginNotUniqueException(val login: String? = null) : RuntimeException()
}
@@ -0,0 +1,10 @@
// 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.exceptions;
import org.jetbrains.annotations.NotNull;
public class GithubParseException extends RuntimeException {
public GithubParseException(@NotNull String message) {
super(message);
}
}
@@ -1,87 +1,30 @@
/*
* Copyright 2000-2016 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jetbrains.plugins.github.ui;
// 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.ui
import com.intellij.openapi.options.Configurable;
import com.intellij.openapi.options.ConfigurationException;
import com.intellij.openapi.options.SearchableConfigurable;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vcs.VcsConfigurableProvider;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.plugins.github.util.GithubUtil;
import com.intellij.openapi.components.service
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurableBase
import com.intellij.openapi.project.Project
import org.jetbrains.plugins.github.authentication.accounts.GithubAccountManager
import org.jetbrains.plugins.github.authentication.accounts.GithubProjectDefaultAccountHolder
import org.jetbrains.plugins.github.util.GithubSettings
import org.jetbrains.plugins.github.util.GithubUtil
import javax.swing.*;
class GithubSettingsConfigurable(private val project: Project) :
ConfigurableBase<GithubSettingsPanel, GithubSettingsConfigurable.GithubSettingsHolder>("settings.github",
GithubUtil.SERVICE_DISPLAY_NAME,
"settings.github"),
Configurable.NoMargin {
/**
* @author oleg
*/
public class GithubSettingsConfigurable implements SearchableConfigurable, VcsConfigurableProvider {
private GithubSettingsPanel mySettingsPane;
public GithubSettingsConfigurable() {
override fun getSettings(): GithubSettingsHolder {
return GithubSettingsHolder(service(), service(), project.service())
}
@NotNull
public String getDisplayName() {
return GithubUtil.SERVICE_DISPLAY_NAME;
override fun createUi(): GithubSettingsPanel {
return GithubSettingsPanel(project, service())
}
@Override
@NotNull
public String getHelpTopic() {
return "settings.github";
}
@NotNull
public JComponent createComponent() {
if (mySettingsPane == null) {
mySettingsPane = new GithubSettingsPanel();
}
return mySettingsPane.getPanel();
}
public boolean isModified() {
return mySettingsPane != null && mySettingsPane.isModified();
}
public void apply() {
if (mySettingsPane != null) {
mySettingsPane.apply();
}
}
public void reset() {
if (mySettingsPane != null) {
mySettingsPane.reset();
}
}
public void disposeUIResources() {
mySettingsPane = null;
}
@NotNull
public String getId() {
return getHelpTopic();
}
@Nullable
@Override
public Configurable getConfigurable(Project project) {
return this;
}
inner class GithubSettingsHolder internal constructor(val application: GithubSettings,
val applicationAccounts: GithubAccountManager,
val projectAccount: GithubProjectDefaultAccountHolder)
}
@@ -1,81 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<form xmlns="http://www.intellij.com/uidesigner/form/" version="1" bind-to-class="org.jetbrains.plugins.github.ui.GithubSettingsPanel">
<grid id="27dc6" binding="myPane" layout-manager="GridLayoutManager" row-count="5" column-count="1" same-size-horizontally="false" same-size-vertically="false" hgap="-1" vgap="-1">
<margin top="0" left="0" bottom="0" right="0"/>
<constraints>
<xy x="20" y="20" width="646" height="400"/>
</constraints>
<properties/>
<clientProperties>
<html.disable class="java.lang.Boolean" value="false"/>
</clientProperties>
<border type="none"/>
<children>
<nested-form id="abfc3" form-file="org/jetbrains/plugins/github/ui/GithubCredentialsPanel.form" binding="myCredentialsPanel" custom-create="true">
<constraints>
<grid row="0" column="0" row-span="1" col-span="1" vsize-policy="3" hsize-policy="3" anchor="0" fill="3" indent="0" use-parent-layout="false"/>
</constraints>
</nested-form>
<component id="9abec" class="com.intellij.ui.TitledSeparator">
<constraints>
<grid row="1" column="0" row-span="1" col-span="1" vsize-policy="0" hsize-policy="3" anchor="0" fill="3" indent="0" use-parent-layout="false"/>
</constraints>
<properties/>
</component>
<component id="e2613" class="com.intellij.ui.components.JBCheckBox" binding="myCloneUsingSshCheckBox" default-binding="true">
<constraints>
<grid row="2" column="0" row-span="1" col-span="1" vsize-policy="0" hsize-policy="0" anchor="8" fill="0" indent="0" use-parent-layout="false"/>
</constraints>
<properties>
<text value="Clone git repositories using ssh"/>
</properties>
</component>
<grid id="d384a" layout-manager="GridLayoutManager" row-count="1" column-count="4" same-size-horizontally="false" same-size-vertically="false" hgap="-1" vgap="-1">
<margin top="0" left="0" bottom="0" right="0"/>
<constraints>
<grid row="3" column="0" row-span="1" col-span="1" vsize-policy="3" hsize-policy="3" anchor="0" fill="3" indent="0" use-parent-layout="false"/>
</constraints>
<properties/>
<clientProperties>
<html.disable class="java.lang.Boolean" value="false"/>
</clientProperties>
<border type="none"/>
<children>
<component id="ae995" class="javax.swing.JLabel">
<constraints>
<grid row="0" column="0" row-span="1" col-span="1" vsize-policy="0" hsize-policy="0" anchor="8" fill="0" indent="0" use-parent-layout="false"/>
</constraints>
<properties>
<labelFor value="cd677"/>
<text value="Connection timeout:"/>
</properties>
</component>
<component id="cd677" class="javax.swing.JSpinner" binding="myTimeoutSpinner" custom-create="true">
<constraints>
<grid row="0" column="1" row-span="1" col-span="1" vsize-policy="0" hsize-policy="2" anchor="8" fill="0" indent="0" use-parent-layout="false"/>
</constraints>
<properties/>
</component>
<component id="628fe" class="javax.swing.JLabel">
<constraints>
<grid row="0" column="2" row-span="1" col-span="1" vsize-policy="0" hsize-policy="0" anchor="8" fill="0" indent="0" use-parent-layout="false"/>
</constraints>
<properties>
<text value="ms"/>
</properties>
</component>
<hspacer id="f53f1">
<constraints>
<grid row="0" column="3" row-span="1" col-span="1" vsize-policy="1" hsize-policy="6" anchor="0" fill="1" indent="0" use-parent-layout="false"/>
</constraints>
</hspacer>
</children>
</grid>
<vspacer id="6ace">
<constraints>
<grid row="4" column="0" row-span="1" col-span="1" vsize-policy="6" hsize-policy="1" anchor="0" fill="2" indent="0" use-parent-layout="false"/>
</constraints>
</vspacer>
</children>
</grid>
</form>
@@ -1,109 +1,96 @@
/*
* Copyright 2000-2016 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jetbrains.plugins.github.ui;
// 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.ui
import com.intellij.openapi.project.ProjectManager;
import com.intellij.openapi.util.Comparing;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.ui.components.JBCheckBox;
import gnu.trove.Equality;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.plugins.github.util.GithubAuthData;
import org.jetbrains.plugins.github.util.GithubSettings;
import com.intellij.openapi.Disposable
import com.intellij.openapi.options.ConfigurableUi
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBCheckBox
import com.intellij.util.ui.JBDimension
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UI.PanelFactory.grid
import com.intellij.util.ui.UI.PanelFactory.panel
import com.intellij.util.ui.UIUtil
import org.jetbrains.plugins.github.authentication.accounts.GithubAccountInformationProvider
import org.jetbrains.plugins.github.authentication.ui.GithubAccountsPanel
import org.jetbrains.plugins.github.util.GithubSettings
import java.awt.Component.LEFT_ALIGNMENT
import java.awt.GridLayout
import java.text.NumberFormat
import javax.swing.*
import javax.swing.text.NumberFormatter
import javax.swing.*;
class GithubSettingsPanel(project: Project, accountInformationProvider: GithubAccountInformationProvider)
: ConfigurableUi<GithubSettingsConfigurable.GithubSettingsHolder>, Disposable {
private val accountsPanel = GithubAccountsPanel(project, accountInformationProvider)
private val timeoutField = JFormattedTextField(NumberFormatter(NumberFormat.getIntegerInstance()).apply {
minimum = 0
maximum = 60
}).apply {
columns = 2
UIUtil.fixFormattedField(this)
}
private val cloneUsingSshCheckBox = JBCheckBox("Clone git repositories using ssh")
public class GithubSettingsPanel {
private final GithubSettings mySettings;
private JPanel myPane;
private JSpinner myTimeoutSpinner;
private JBCheckBox myCloneUsingSshCheckBox;
private GithubCredentialsPanel myCredentialsPanel;
public GithubSettingsPanel() {
mySettings = GithubSettings.getInstance();
reset();
override fun reset(settings: GithubSettingsConfigurable.GithubSettingsHolder) {
accountsPanel.setAccounts(settings.applicationAccounts.accounts, settings.projectAccount.account)
accountsPanel.clearNewTokens()
accountsPanel.loadExistingAccountsDetails()
timeoutField.value = settings.application.getConnectionTimeoutSeconds()
cloneUsingSshCheckBox.isSelected = settings.application.isCloneGitUsingSsh
}
public JComponent getPanel() {
return myPane;
override fun isModified(settings: GithubSettingsConfigurable.GithubSettingsHolder): Boolean {
return timeoutField.value != settings.application.getConnectionTimeoutSeconds() ||
cloneUsingSshCheckBox.isSelected != settings.application.isCloneGitUsingSsh ||
accountsPanel.isModified(settings.applicationAccounts.accounts, settings.projectAccount.account)
}
@NotNull
public GithubAuthData getAuthData() {
GithubAuthData authData = myCredentialsPanel.getAuthData();
if (authData.getBasicAuth() != null && StringUtil.isEmptyOrSpaces(authData.getBasicAuth().getLogin()) ||
authData.getTokenAuth() != null && StringUtil.isEmptyOrSpaces(authData.getTokenAuth().getToken())) {
return GithubAuthData.createAnonymous(myCredentialsPanel.getHost());
override fun apply(settings: GithubSettingsConfigurable.GithubSettingsHolder) {
val (accountsTokenMap, defaultAccount) = accountsPanel.getAccounts()
settings.applicationAccounts.accounts = accountsTokenMap.keys
accountsTokenMap.filterValues { it != null }.forEach(settings.applicationAccounts::updateAccountToken)
settings.projectAccount.account = defaultAccount
accountsPanel.clearNewTokens()
settings.application.setConnectionTimeoutSeconds(timeoutField.value as Int)
settings.application.isCloneGitUsingSsh = cloneUsingSshCheckBox.isSelected
}
private fun GithubSettings.getConnectionTimeoutSeconds(): Int {
return connectionTimeout / 1000
}
private fun GithubSettings.setConnectionTimeoutSeconds(timeout: Int) {
connectionTimeout = timeout * 1000
}
override fun getComponent(): JComponent {
val timeoutPanel = JPanel().apply {
layout = BoxLayout(this, BoxLayout.X_AXIS)
add(JLabel("Connection timeout:"))
add(Box.createRigidArea(JBDimension(UIUtil.DEFAULT_HGAP, 0)))
add(timeoutField)
add(Box.createRigidArea(JBDimension(UIUtil.DEFAULT_HGAP, 0)))
add(JLabel("seconds"))
alignmentX = LEFT_ALIGNMENT
}
return authData;
}
val settingsPanel = grid()
.add(panel(cloneUsingSshCheckBox.apply { alignmentX = LEFT_ALIGNMENT }))
.add(panel(timeoutPanel).resizeX(false))
.createPanel()
.apply {
border = JBUI.Borders.empty(UIUtil.DEFAULT_VGAP, UIUtil.DEFAULT_HGAP)
}
public void setConnectionTimeout(int timeout) {
myTimeoutSpinner.setValue(Integer.valueOf(timeout));
}
public int getConnectionTimeout() {
return ((SpinnerNumberModel)myTimeoutSpinner.getModel()).getNumber().intValue();
}
public void reset() {
myCredentialsPanel.setAuthData(mySettings.getAuthData());
setConnectionTimeout(mySettings.getConnectionTimeout());
myCloneUsingSshCheckBox.setSelected(mySettings.isCloneGitUsingSsh());
}
public void apply() {
if (!equal(mySettings.getAuthData(), getAuthData())) {
mySettings.setAuthData(getAuthData(), true);
return JPanel().apply {
layout = GridLayout(2, 1)
add(accountsPanel)
add(settingsPanel)
}
mySettings.setConnectionTimeout(getConnectionTimeout());
mySettings.setCloneGitUsingSsh(myCloneUsingSshCheckBox.isSelected());
}
public boolean isModified() {
return !equal(mySettings.getAuthData(), getAuthData()) ||
!Comparing.equal(mySettings.getConnectionTimeout(), getConnectionTimeout()) ||
!Comparing.equal(mySettings.isCloneGitUsingSsh(), myCloneUsingSshCheckBox.isSelected());
}
private void createUIComponents() {
myCredentialsPanel = new GithubCredentialsPanel(ProjectManager.getInstance().getDefaultProject());
myTimeoutSpinner = new JSpinner(new SpinnerNumberModel(5000, 0, 60000, 500));
}
private static boolean equal(@NotNull GithubAuthData data1, @NotNull GithubAuthData data2) {
return Comparing.equal(data1.getHost(), data2.getHost()) &&
Comparing.equal(data1.getAuthType(), data2.getAuthType()) &&
equal(data1.getBasicAuth(), data2.getBasicAuth(),
(auth1, auth2) -> Comparing.equal(auth1.getLogin(), auth2.getLogin()) &&
Comparing.equal(auth1.getPassword(), auth2.getPassword())) &&
equal(data1.getTokenAuth(), data2.getTokenAuth(),
(auth1, auth2) -> Comparing.equal(auth1.getToken(), auth2.getToken()));
}
private static <T> boolean equal(@Nullable T o1, @Nullable T o2, @NotNull Equality<T> notNullEquality) {
if (o1 == o2) return true;
if (o1 == null) return false;
if (o2 == null) return false;
return notNullEquality.equals(o1, o2);
override fun dispose() {
Disposer.dispose(accountsPanel)
}
}
@@ -0,0 +1,21 @@
// 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.ui.util
import com.intellij.openapi.ui.ValidationInfo
import javax.swing.JTextField
object DialogValidationUtils {
/**
* Returns [ValidationInfo] with [message] if [textField] is blank
*/
fun notBlank(textField: JTextField, message: String): ValidationInfo? {
return if (textField.text.isNullOrBlank()) ValidationInfo(message, textField) else null
}
/**
* Chains the [validators] so that if one of them returns non-null [ValidationInfo] the rest of them are not checked
*/
fun chain(vararg validators: Validator): Validator = { validators.asSequence().mapNotNull { it() }.firstOrNull() }
}
typealias Validator = () -> ValidationInfo?
@@ -72,6 +72,7 @@ public class GithubUtil {
public static final Logger LOG = Logger.getInstance("github");
public static final String SERVICE_DISPLAY_NAME = "GitHub";
public static final String DEFAULT_TOKEN_NOTE = "IntelliJ Plugin";
// TODO: Consider sharing of GithubAuthData between actions (as member of GithubSettings)
public static <T> T runTask(@NotNull Project project,