[space] IDEA-262242: Detect that project can be associated with Space repositories and show reviews toolwindow then

GitOrigin-RevId: aef680f3ea840f673004e12e985177f91fd4e2fe
This commit is contained in:
Nikolay Rykunov
2021-02-25 12:28:03 +00:00
committed by intellij-monorepo-bot
parent 58021594f0
commit 9cfb63c583
12 changed files with 215 additions and 101 deletions
@@ -52,7 +52,9 @@ internal class GitHostingUrlUtilTest {
"https://github.com:2233/user/repo/" to URI("https", null, "github.com", 2233, "/user/repo", null, null),
"HTTP://GITHUB.com/user/repo/" to URI("HTTP", null, "GITHUB.com", -1, "/user/repo", null, null),
"HttP://GitHub.com/user/repo/" to URI("HttP", null, "GitHub.com", -1, "/user/repo", null, null),
"git@github.com:user/repo/" to URI("https", null, "github.com", -1, "/user/repo", null, null)
"git@github.com:user/repo/" to URI("https", null, "github.com", -1, "/user/repo", null, null),
"ssh://git@git.jetbrains.space/company/project/repository.git" to URI("ssh", "git", "git.jetbrains.space", -1, "/company/project/repository", null, null),
"https://git.jetbrains.space/company/project/repository.git" to URI("https", null, "git.jetbrains.space", -1, "/company/project/repository", null, null)
), GitHostingUrlUtil::getUriFromRemoteUrl)
}
}
@@ -112,7 +112,7 @@ class SpaceMainToolBarAction : DumbAwareAction(), RightAlignedToolbarAction {
{ SpaceCloneAction.runClone(project) },
showSeparatorAbove = true)
val projectContext = SpaceProjectContext.getInstance(project)
val context = projectContext.context.value
val context = projectContext.currentContext
if (context.isAssociatedWithSpaceRepository) {
val descriptions = context.reposInProject.keys
if (descriptions.size > 1) {
@@ -68,7 +68,7 @@ abstract class SpaceOpenInBrowserAction(@NlsActions.ActionText groupName: String
val project = e.project
if (project != null) {
val projectContext = SpaceProjectContext.getInstance(project)
if (projectContext.context.value.isAssociatedWithSpaceRepository) {
if (projectContext.currentContext.isAssociatedWithSpaceRepository) {
e.presentation.isEnabledAndVisible = true
return
@@ -88,7 +88,7 @@ abstract class SpaceOpenInBrowserAction(@NlsActions.ActionText groupName: String
urlsBuilder: (ProjectKey) -> String
): List<Pair<SpaceProjectInfo, String>>? {
val project = context.getData(CommonDataKeys.PROJECT) ?: return null
val description = SpaceProjectContext.getInstance(project).context.value
val description = SpaceProjectContext.getInstance(project).currentContext
return description.reposInProject.keys.map {
it to urlsBuilder(it.key)
@@ -13,15 +13,15 @@ import com.intellij.openapi.project.Project
import com.intellij.space.components.SpaceWorkspaceComponent
import com.intellij.space.utils.LifetimedDisposable
import com.intellij.space.utils.LifetimedDisposableImpl
import com.intellij.space.vcs.hosting.SpaceGitHostingChecker
import git4idea.GitUtil
import git4idea.repo.GitRemote
import git4idea.repo.GitRepository
import git4idea.repo.GitRepositoryChangeListener
import libraries.coroutines.extra.launch
import runtime.Ui
import runtime.async.backoff
import runtime.reactive.MutableProperty
import runtime.reactive.Property
import runtime.reactive.awaitFirst
import runtime.reactive.filter
import runtime.reactive.*
import runtime.reactive.property.mapInit
@Service
@@ -29,27 +29,40 @@ class SpaceProjectContext(project: Project) : LifetimedDisposable by LifetimedDi
private val remoteUrls: MutableProperty<Set<GitRemoteUrlCoordinates>> = Property.createMutable(findRemoteUrls(project))
val context: Property<Context> = lifetime.mapInit(SpaceWorkspaceComponent.getInstance().workspace, remoteUrls, EMPTY) { ws, urls ->
ws ?: return@mapInit EMPTY
private val hostingChecker = SpaceGitHostingChecker()
val probablyContainsSpaceRepo = lifetime.mapInit(remoteUrls, false) { urls ->
hostingChecker.check(urls.map { it.remote }.toSet())
}
val context: LoadingProperty<Context> = lifetime.load(SpaceWorkspaceComponent.getInstance().workspace, remoteUrls) { ws, urls ->
ws ?: return@load EMPTY
ws.client.connectionStatus.filter { it is ConnectionStatus.Connected }.awaitFirst(ws.lifetime)
reloadProjectKeys(ws, urls)
}
// use it as rare as possible
// prefer to subscribe on [context]
val currentContext: Context
get() = (context.value as? LoadingValue.Loaded)?.value ?: EMPTY
init {
project.messageBus
.connect(this)
.subscribe(GitRepository.GIT_REPO_CHANGE, GitRepositoryChangeListener {
val newUrls = findRemoteUrls(project)
remoteUrls.value = newUrls
launch(lifetime, Ui) {
val newUrls = findRemoteUrls(project)
remoteUrls.value = newUrls
}
})
}
fun getRepoDescriptionByUrl(remoteUrl: String): SpaceRepoInfo? {
val coordinates = context.value.repoByUrl.keys.find {
val coordinates = currentContext.repoByUrl.keys.find {
it.url == remoteUrl
}
return context.value.repoByUrl[coordinates]
return currentContext.repoByUrl[coordinates]
}
private fun findRemoteUrls(project: Project): Set<GitRemoteUrlCoordinates> {
return GitUtil.getRepositoryManager(project).repositories.flatMap { gitRepo ->
@@ -0,0 +1,31 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.space.vcs.hosting
import com.intellij.util.hosting.GitHostingUrlUtil
import git4idea.repo.GitRemote
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol
internal class SpaceGitHostingChecker {
companion object {
private const val SPACE_HOSTING_RESPONSE_CONTENT = "JetBrains Space - VCS hosting"
}
private val httpClient: HttpClient = HttpClient()
suspend fun check(remotes: Set<GitRemote>): Boolean {
for (remote in remotes) {
val url = remote.firstUrl ?: continue
val hosting = GitHostingUrlUtil.getUriFromRemoteUrl(url) ?: continue
val port = hosting.port.takeIf { it != -1 } ?: URLProtocol.HTTPS.defaultPort
val urlToCheck = URLBuilder(protocol = URLProtocol.HTTPS, host = hosting.host, port = port).build()
val isSpaceRepo = httpClient.get<String>(urlToCheck).contains(SPACE_HOSTING_RESPONSE_CONTENT)
if (isSpaceRepo) {
return true
}
}
return false
}
}
@@ -1,65 +0,0 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.space.vcs.review
import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import com.intellij.space.components.SpaceWorkspaceComponent
import com.intellij.space.messages.SpaceBundle
import com.intellij.space.settings.SpaceSettingsPanel
import com.intellij.space.vcs.SpaceProjectInfo
import com.intellij.space.vcs.SpaceRepoInfo
import com.intellij.space.vcs.review.list.SpaceReviewsListVmImpl
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.panels.Wrapper
import com.intellij.util.ui.UIUtil
import libraries.coroutines.extra.Lifetime
import runtime.reactive.property.map
internal class ReviewLoginComponent(
parentDisposable: Disposable,
lifetime: Lifetime,
project: Project,
spaceProjectInfo: SpaceProjectInfo,
spaceRepos: Set<SpaceRepoInfo>
) {
private val isLoggedIn = lifetime.map(SpaceWorkspaceComponent.getInstance().workspace) {
it != null
}
val view = Wrapper().apply {
background = UIUtil.getListBackground()
}
init {
isLoggedIn.forEach(lifetime) { isLoggedIn ->
if (!isLoggedIn) {
val loginLabel = ActionLink(SpaceBundle.message("action.com.intellij.space.actions.SpaceLoginAction.text")) {
SpaceSettingsPanel.openSettings(null)
}
view.setContent(loginLabel)
}
else {
val workspace = SpaceWorkspaceComponent.getInstance().workspace.value!!
val client = workspace.client
val reviewsListVm = SpaceReviewsListVmImpl(lifetime,
client,
spaceProjectInfo,
workspace.me)
val reviewComponent = SpaceReviewComponent(parentDisposable,
project,
lifetime,
spaceProjectInfo,
spaceRepos,
workspace,
reviewsListVm,
SpaceSelectedReviewVmImpl(workspace, spaceProjectInfo))
view.setContent(reviewComponent)
}
view.validate()
view.repaint()
}
}
}
@@ -7,6 +7,7 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.space.components.SpaceWorkspaceComponent
import com.intellij.space.utils.LifetimedDisposable
import com.intellij.space.utils.LifetimedDisposableImpl
import com.intellij.space.vcs.Context
@@ -19,6 +20,8 @@ import com.intellij.ui.content.ContentManager
import icons.SpaceIcons
import libraries.coroutines.extra.Lifetime
import libraries.coroutines.extra.LifetimeSource
import runtime.reactive.LoadingProperty
import runtime.reactive.LoadingValue
import runtime.reactive.Property
import runtime.reactive.property.mapInit
@@ -28,16 +31,14 @@ internal class SpaceCodeReviewTabManager(private val project: Project) : Lifetim
private var myReviewTabContentManager: SpaceCodeReviewTabContentManager? = null
init {
project.service<SpaceProjectContext>().context.forEach(lifetime) { context ->
project.service<SpaceProjectContext>().probablyContainsSpaceRepo.forEach(lifetime) {
val toolWindow: ToolWindow = ToolWindowManager
.getInstance(project)
.getToolWindow(SpaceReviewToolWindowFactory.ID) ?: return@forEach
val isAvailable = context.isAssociatedWithSpaceRepository
if (isAvailable && !toolWindow.isAvailable) {
if (it && !toolWindow.isAvailable) {
toolWindow.isShowStripeButton = true
}
toolWindow.isAvailable = isAvailable
toolWindow.isAvailable = it
}
}
@@ -55,13 +56,13 @@ internal class SpaceCodeReviewTabManager(private val project: Project) : Lifetim
internal class SpaceCodeReviewTabContentManager(private val project: Project,
private val contentManager: ContentManager,
lifetime: Lifetime) {
private val context: Property<Context> = SpaceProjectContext.getInstance(project).context
private val context: LoadingProperty<Context> = SpaceProjectContext.getInstance(project).context
private val contents: Property<MutableMap<SpaceProjectInfo, Content>> = lifetime.mapInit(context, mutableMapOf()) { context ->
if (!context.isAssociatedWithSpaceRepository) {
private val contents: Property<MutableMap<SpaceProjectInfo, Content>> = lifetime.mapInit(context, mutableMapOf()) { loadingContext ->
if (loadingContext !is LoadingValue.Loaded || !loadingContext.value.isAssociatedWithSpaceRepository) {
return@mapInit mutableMapOf<SpaceProjectInfo, Content>()
}
val context = loadingContext.value
val result = HashMap<SpaceProjectInfo, Content>()
context.reposInProject.forEach {
val content = createContent(project, it.key, it.value)
@@ -73,6 +74,9 @@ internal class SpaceCodeReviewTabContentManager(private val project: Project,
init {
contents.forEachWithPrevious(lifetime) { prev: MutableMap<SpaceProjectInfo, Content>?, next: MutableMap<SpaceProjectInfo, Content> ->
val previous = prev ?: emptyMap()
if (previous.isEmpty()) {
contentManager.removeAllContents(true)
}
previous.keys
.filter { key -> !next.keys.contains(key) }
.forEach {
@@ -86,25 +90,41 @@ internal class SpaceCodeReviewTabContentManager(private val project: Project,
contentManager.addContent(content)
contentManager.setSelectedContent(content)
}
if (next.isEmpty()) {
val loginContent = createEmptyContent(project)
contentManager.addContent(loginContent)
contentManager.setSelectedContent(loginContent)
}
}
}
private fun createContent(project: Project,
spaceProjectInfo: SpaceProjectInfo,
projectRepos: Set<SpaceRepoInfo>): Content {
private fun createEmptyContent(project: Project) = createDisposableContent { content, _, contentLifetime ->
content.component = SpaceReviewToolwindowEmptyComponent(project, contentLifetime)
}
private fun createContent(
project: Project,
spaceProjectInfo: SpaceProjectInfo,
projectRepos: Set<SpaceRepoInfo>
): Content = createDisposableContent { content, disposable, contentLifetime ->
content.displayName = spaceProjectInfo.project.name // NON-NLS
content.isCloseable = false
content.icon = SpaceIcons.Main
val workspace = SpaceWorkspaceComponent.getInstance().workspace.value!!
content.component = SpaceReviewToolwindowTabComponent(disposable, contentLifetime, project, workspace, spaceProjectInfo, projectRepos)
content.description = spaceProjectInfo.key.key // NON-NLS
}
private fun createDisposableContent(modifier: (Content, Disposable, Lifetime) -> Unit): Content {
val contentLifetime = LifetimeSource()
val factory = ContentFactory.SERVICE.getInstance()
return factory.createContent(null, spaceProjectInfo.project.name, false).apply { // NON-NLS
return factory.createContent(null, null, false).apply {
val disposable = Disposable {
contentLifetime.terminate()
}
isCloseable = false
setDisposer(disposable)
icon = SpaceIcons.Main
component = ReviewLoginComponent(disposable, contentLifetime, project, spaceProjectInfo, projectRepos).view
description = spaceProjectInfo.key.key // NON-NLS
modifier(this, disposable, contentLifetime)
}
}
}
@@ -0,0 +1,66 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.space.vcs.review
import com.intellij.openapi.project.Project
import com.intellij.space.components.SpaceWorkspaceComponent
import com.intellij.space.messages.SpaceBundle
import com.intellij.space.settings.SpaceSettingsPanel
import com.intellij.space.vcs.SpaceProjectContext
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.UIUtil
import libraries.coroutines.extra.Lifetime
import net.miginfocom.layout.CC
import net.miginfocom.layout.LC
import net.miginfocom.swing.MigLayout
import runtime.reactive.Property
import runtime.reactive.isLoading
import runtime.reactive.property.map
import javax.swing.JPanel
internal class SpaceReviewToolwindowEmptyComponent(project: Project, lifetime: Lifetime) : JPanel() {
private val workspaceComponent = SpaceWorkspaceComponent.getInstance()
private val projectContext = SpaceProjectContext.getInstance(project)
private val emptyState: Property<EmptyState> = lifetime.map(workspaceComponent.workspace, projectContext.context) { workspace, context ->
when {
workspace == null -> EmptyState.NOT_LOGGED_IN
context.isLoading -> EmptyState.CONNECTING_TO_REPOS
else -> EmptyState.NOT_ASSOCIATED
}
}
init {
background = UIUtil.getListBackground()
layout = MigLayout(LC().gridGap("0", "0")
.insets("0", "0", "0", "0")
.fill())
val loginLabel = ActionLink(SpaceBundle.message("action.com.intellij.space.actions.SpaceLoginAction.text")) {
SpaceSettingsPanel.openSettings(null)
}
val connectingToReposLabel = JBLabel(SpaceBundle.message("review.toolwindow.empty.connecting.to.repositories.label")).apply {
foreground = UIUtil.getContextHelpForeground()
}
val isNotAssociatedLabel = JBLabel(SpaceBundle.message("review.toolwindow.empty.is.not.associated.label")).apply {
foreground = UIUtil.getContextHelpForeground()
}
emptyState.forEach(lifetime) {
removeAll()
val component = when (it) {
EmptyState.NOT_LOGGED_IN -> loginLabel
EmptyState.CONNECTING_TO_REPOS -> connectingToReposLabel
EmptyState.NOT_ASSOCIATED -> isNotAssociatedLabel
}
add(component, CC().alignX("center").alignY("center"))
revalidate()
repaint()
}
}
private enum class EmptyState {
NOT_LOGGED_IN,
CONNECTING_TO_REPOS,
NOT_ASSOCIATED
}
}
@@ -0,0 +1,45 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.space.vcs.review
import circlet.workspaces.Workspace
import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import com.intellij.space.vcs.SpaceProjectInfo
import com.intellij.space.vcs.SpaceRepoInfo
import com.intellij.space.vcs.review.list.SpaceReviewsListVmImpl
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import libraries.coroutines.extra.Lifetime
internal class SpaceReviewToolwindowTabComponent(
parentDisposable: Disposable,
lifetime: Lifetime,
project: Project,
workspace: Workspace,
spaceProjectInfo: SpaceProjectInfo,
spaceRepos: Set<SpaceRepoInfo>
) : BorderLayoutPanel() {
init {
background = UIUtil.getListBackground()
val client = workspace.client
val reviewsListVm = SpaceReviewsListVmImpl(
lifetime,
client,
spaceProjectInfo,
workspace.me
)
val reviewComponent = SpaceReviewComponent(
parentDisposable,
project,
lifetime,
spaceProjectInfo,
spaceRepos,
workspace,
reviewsListVm,
SpaceSelectedReviewVmImpl(workspace, spaceProjectInfo)
)
addToCenter(reviewComponent)
}
}
@@ -1,4 +1,4 @@
// Copyright 2000-2020 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.
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.intellij.space.vcs.review
import com.intellij.openapi.actionSystem.AnActionEvent
@@ -23,7 +23,7 @@ class SpaceShowReviewsAction : DumbAwareAction(SpaceBundle.messagePointer("actio
private fun isAssociatedWithSpaceRepository(e: AnActionEvent): Boolean {
val project = e.project ?: return false
return SpaceProjectContext.getInstance(project).context.value.isAssociatedWithSpaceRepository
return SpaceProjectContext.getInstance(project).currentContext.isAssociatedWithSpaceRepository
}
}
@@ -70,7 +70,7 @@ private class SpaceShareProjectAction : DumbAwareAction() {
}
val context = SpaceProjectContext.getInstance(project)
if (context.context.value.isAssociatedWithSpaceRepository) {
if (context.currentContext.isAssociatedWithSpaceRepository) {
e.presentation.isEnabledAndVisible = false
return
}
@@ -203,6 +203,8 @@ review.list.empty=No matching reviews
review.loading.reviews=Loading...
review.reviewers.selector.list.empty.text=No possible reviewers
review.reviewers.selector.loading=Loading...
review.toolwindow.empty.connecting.to.repositories.label=Connecting to Space repositories...
review.toolwindow.empty.is.not.associated.label=Could not associate project with Space repositories
toolwindow.stripe.Space_Automation=Space Automation
toolwindow.stripe.Space_Code_Reviews=Space Code Reviews