diff --git a/platform/vcs-code-review/test/com/intellij/util/hosting/GitHostingUrlUtilTest.kt b/platform/vcs-code-review/test/com/intellij/util/hosting/GitHostingUrlUtilTest.kt index 9000528712ca..7de165389891 100644 --- a/platform/vcs-code-review/test/com/intellij/util/hosting/GitHostingUrlUtilTest.kt +++ b/platform/vcs-code-review/test/com/intellij/util/hosting/GitHostingUrlUtilTest.kt @@ -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) } } \ No newline at end of file diff --git a/plugins/space/src/main/kotlin/com/intellij/space/actions/SpaceMainToolBarAction.kt b/plugins/space/src/main/kotlin/com/intellij/space/actions/SpaceMainToolBarAction.kt index 57bf7fa8b33b..92f6e8cba95c 100644 --- a/plugins/space/src/main/kotlin/com/intellij/space/actions/SpaceMainToolBarAction.kt +++ b/plugins/space/src/main/kotlin/com/intellij/space/actions/SpaceMainToolBarAction.kt @@ -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) { diff --git a/plugins/space/src/main/kotlin/com/intellij/space/vcs/SpaceOpenInBrowserAction.kt b/plugins/space/src/main/kotlin/com/intellij/space/vcs/SpaceOpenInBrowserAction.kt index de90dba31a7e..0b82f7c7a4e1 100644 --- a/plugins/space/src/main/kotlin/com/intellij/space/vcs/SpaceOpenInBrowserAction.kt +++ b/plugins/space/src/main/kotlin/com/intellij/space/vcs/SpaceOpenInBrowserAction.kt @@ -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>? { 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) diff --git a/plugins/space/src/main/kotlin/com/intellij/space/vcs/SpaceProjectContext.kt b/plugins/space/src/main/kotlin/com/intellij/space/vcs/SpaceProjectContext.kt index 02e2d74e5348..96707ccff028 100644 --- a/plugins/space/src/main/kotlin/com/intellij/space/vcs/SpaceProjectContext.kt +++ b/plugins/space/src/main/kotlin/com/intellij/space/vcs/SpaceProjectContext.kt @@ -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> = Property.createMutable(findRemoteUrls(project)) - val context: Property = 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 = 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 { return GitUtil.getRepositoryManager(project).repositories.flatMap { gitRepo -> diff --git a/plugins/space/src/main/kotlin/com/intellij/space/vcs/hosting/SpaceGitHostingChecker.kt b/plugins/space/src/main/kotlin/com/intellij/space/vcs/hosting/SpaceGitHostingChecker.kt new file mode 100644 index 000000000000..cc342ffb4871 --- /dev/null +++ b/plugins/space/src/main/kotlin/com/intellij/space/vcs/hosting/SpaceGitHostingChecker.kt @@ -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): 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(urlToCheck).contains(SPACE_HOSTING_RESPONSE_CONTENT) + if (isSpaceRepo) { + return true + } + } + return false + } +} \ No newline at end of file diff --git a/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/ReviewLoginComponent.kt b/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/ReviewLoginComponent.kt deleted file mode 100644 index b7cb80c18cfc..000000000000 --- a/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/ReviewLoginComponent.kt +++ /dev/null @@ -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 -) { - - 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() - } - } -} diff --git a/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewTabManager.kt b/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewTabManager.kt index 0ce304940eaa..ec53b7ac5cb3 100644 --- a/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewTabManager.kt +++ b/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewTabManager.kt @@ -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().context.forEach(lifetime) { context -> + project.service().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 = SpaceProjectContext.getInstance(project).context + private val context: LoadingProperty = SpaceProjectContext.getInstance(project).context - private val contents: Property> = lifetime.mapInit(context, mutableMapOf()) { context -> - if (!context.isAssociatedWithSpaceRepository) { + private val contents: Property> = lifetime.mapInit(context, mutableMapOf()) { loadingContext -> + if (loadingContext !is LoadingValue.Loaded || !loadingContext.value.isAssociatedWithSpaceRepository) { return@mapInit mutableMapOf() } - + val context = loadingContext.value val result = HashMap() 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?, next: MutableMap -> 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): Content { + private fun createEmptyContent(project: Project) = createDisposableContent { content, _, contentLifetime -> + content.component = SpaceReviewToolwindowEmptyComponent(project, contentLifetime) + } + + private fun createContent( + project: Project, + spaceProjectInfo: SpaceProjectInfo, + projectRepos: Set + ): 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) } } } diff --git a/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewToolwindowEmptyComponent.kt b/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewToolwindowEmptyComponent.kt new file mode 100644 index 000000000000..bd5694e45f8e --- /dev/null +++ b/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewToolwindowEmptyComponent.kt @@ -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 = 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 + } +} diff --git a/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewToolwindowTabComponent.kt b/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewToolwindowTabComponent.kt new file mode 100644 index 000000000000..c8bccaece33f --- /dev/null +++ b/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceReviewToolwindowTabComponent.kt @@ -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 +) : 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) + } +} \ No newline at end of file diff --git a/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceShowReviewsAction.kt b/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceShowReviewsAction.kt index 76e2cdccae96..bdcbffe8338c 100644 --- a/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceShowReviewsAction.kt +++ b/plugins/space/src/main/kotlin/com/intellij/space/vcs/review/SpaceShowReviewsAction.kt @@ -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 } } diff --git a/plugins/space/src/main/kotlin/com/intellij/space/vcs/share/SpaceShareProjectAction.kt b/plugins/space/src/main/kotlin/com/intellij/space/vcs/share/SpaceShareProjectAction.kt index 899baf8a5f89..7e079dc91786 100644 --- a/plugins/space/src/main/kotlin/com/intellij/space/vcs/share/SpaceShareProjectAction.kt +++ b/plugins/space/src/main/kotlin/com/intellij/space/vcs/share/SpaceShareProjectAction.kt @@ -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 } diff --git a/plugins/space/src/main/resources/messages/SpaceBundle.properties b/plugins/space/src/main/resources/messages/SpaceBundle.properties index b096cb8885d4..7157e00288f9 100644 --- a/plugins/space/src/main/resources/messages/SpaceBundle.properties +++ b/plugins/space/src/main/resources/messages/SpaceBundle.properties @@ -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