(IJPL-173684, IJ-CR-152618) What's New: separate opening logic for web pages

#IJPL-173684 Fixed

(cherry picked from commit 46ee26a3a82e70cee2d71dccba8536a058523d05)

GitOrigin-RevId: 7309170a8ba2f309cd25128daf1b2842f0dd48a8
This commit is contained in:
Ivan Migalev
2025-01-04 19:05:03 +00:00
committed by intellij-monorepo-bot
parent a03adc096e
commit c9078c27f3
4 changed files with 121 additions and 155 deletions

View File

@@ -1,4 +1,4 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.whatsNew package com.intellij.platform.whatsNew
import com.intellij.ide.DataManager import com.intellij.ide.DataManager
@@ -9,25 +9,16 @@ import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.application.ApplicationNamesInfo import com.intellij.openapi.application.ApplicationNamesInfo
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.writeIntentReadAction
import com.intellij.openapi.components.Service import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service import com.intellij.openapi.components.service
import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.diagnostic.runAndLogException import com.intellij.openapi.diagnostic.runAndLogException
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorManagerListener
import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider.Companion.openEditor
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.platform.whatsNew.collectors.WhatsNewCounterUsageCollector
import com.intellij.platform.whatsNew.reaction.FUSReactionChecker import com.intellij.platform.whatsNew.reaction.FUSReactionChecker
import com.intellij.platform.whatsNew.reaction.ReactionsPanel import kotlinx.coroutines.CoroutineScope
import com.intellij.ui.jcef.JBCefApp import kotlinx.coroutines.ExperimentalCoroutinesApi
import com.intellij.util.application import kotlinx.coroutines.async
import kotlinx.coroutines.* import kotlinx.coroutines.launch
import org.jetbrains.concurrency.await import org.jetbrains.concurrency.await
internal class WhatsNewAction : AnAction(), com.intellij.openapi.project.DumbAware { internal class WhatsNewAction : AnAction(), com.intellij.openapi.project.DumbAware {
@@ -57,46 +48,15 @@ internal class WhatsNewAction : AnAction(), com.intellij.openapi.project.DumbAwa
openWhatsNewPage(project, dataContext) openWhatsNewPage(project, dataContext)
} }
private suspend fun openWhatsNewPage(project: Project, dataContext: DataContext?, byClient: Boolean = false) { private suspend fun openWhatsNewPage(project: Project, dataContext: DataContext?, triggeredByUser: Boolean = false) {
if (JBCefApp.isSupported()) {
val content = contentAsync.await() val content = contentAsync.await()
if (content != null && content.isAvailable()) { if (content != null && content.isAvailable()) {
openContent(project, content, dataContext, byClient) content.show(project, dataContext, triggeredByUser, reactionChecker)
} }
else { else {
LOG.warn("EapWhatsNew: can't be shown. Content not available") LOG.warn("EapWhatsNew: can't be shown. Content not available")
} }
} }
else {
LOG.warn("EapWhatsNew: can't be shown. JBCefApp isn't supported")
}
}
private suspend fun openContent(project: Project, whatsNewContent: WhatsNewContent, dataContext: DataContext?, byClient: Boolean) {
check(JBCefApp.isSupported()) { "JCEF is not supported on this system" }
val title = IdeBundle.message("update.whats.new", ApplicationNamesInfo.getInstance().fullProductName)
withContext(Dispatchers.EDT) {
LOG.info("Opening What's New in editor.")
val editor = writeIntentReadAction { openEditor(project, title, whatsNewContent.getRequest(dataContext)) }
editor?.let {
project.serviceAsync<FileEditorManager>().addTopComponent(it, ReactionsPanel.createPanel(PLACE, reactionChecker))
WhatsNewCounterUsageCollector.openedPerformed(project, byClient)
WhatsNewContentVersionChecker.saveLastShownContent(whatsNewContent)
val disposable = Disposer.newDisposable(project)
val busConnection = application.messageBus.connect(disposable)
busConnection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener {
override fun fileClosed(source: FileEditorManager, file: VirtualFile) {
if (it.file == file) {
WhatsNewCounterUsageCollector.closedPerformed(project)
Disposer.dispose(disposable)
}
}
})
}
}
}
override fun getActionUpdateThread(): ActionUpdateThread { override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.BGT return ActionUpdateThread.BGT

View File

@@ -1,37 +1,41 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.whatsNew package com.intellij.platform.whatsNew
import com.intellij.ide.BrowserUtil
import com.intellij.ide.IdeBundle import com.intellij.ide.IdeBundle
import com.intellij.l10n.LocalizationStateService import com.intellij.l10n.LocalizationStateService
import com.intellij.openapi.actionSystem.* import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.*
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.service import com.intellij.openapi.components.service
import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.diagnostic.trace import com.intellij.openapi.diagnostic.trace
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorManagerListener
import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider
import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider.Companion.openEditor
import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider.JsQueryHandler import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider.JsQueryHandler
import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider.Request.Companion.html import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider.Request.Companion.html
import com.intellij.openapi.fileEditor.impl.HTMLEditorProvider.Request.Companion.url
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.Version import com.intellij.openapi.util.Version
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.platform.ide.customization.ExternalProductResourceUrls import com.intellij.platform.ide.customization.ExternalProductResourceUrls
import com.intellij.platform.whatsNew.WhatsNewAction.Companion.PLACE
import com.intellij.platform.whatsNew.collectors.WhatsNewCounterUsageCollector import com.intellij.platform.whatsNew.collectors.WhatsNewCounterUsageCollector
import com.intellij.util.Urls.newFromEncoded import com.intellij.platform.whatsNew.reaction.FUSReactionChecker
import com.intellij.platform.whatsNew.reaction.ReactionsPanel
import com.intellij.ui.jcef.JBCefApp
import com.intellij.util.application
import com.intellij.util.io.DigestUtil import com.intellij.util.io.DigestUtil
import com.intellij.util.ui.StartupUiUtil import com.intellij.util.ui.StartupUiUtil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.nio.charset.StandardCharsets
internal abstract class WhatsNewContent { internal abstract class WhatsNewContent {
companion object { companion object {
private val DataContext.project: Project?
get() = CommonDataKeys.PROJECT.getData(this)
suspend fun getWhatsNewContent(): WhatsNewContent? { suspend fun getWhatsNewContent(): WhatsNewContent? {
return if (WhatsNewInVisionContentProvider.getInstance().isAvailable()) { return if (WhatsNewInVisionContentProvider.getInstance().isAvailable()) {
WhatsNewVisionContent(WhatsNewInVisionContentProvider.getInstance().getContent().entities.first()) WhatsNewVisionContent(WhatsNewInVisionContentProvider.getInstance().getContent().entities.first())
@@ -41,7 +45,8 @@ internal abstract class WhatsNewContent {
} }
} }
// Year and release have to be strings, because this is the ApplicationInfo.xml format. "release" might be a string like "2.1". // Year and release have to be strings, because this is the ApplicationInfo.xml format.
// Remember that "release" might be a string like "2.1".
data class ContentVersion(val year: String, val release: String, val eap: Int?, val hash: String?) : Comparable<ContentVersion> { data class ContentVersion(val year: String, val release: String, val eap: Int?, val hash: String?) : Comparable<ContentVersion> {
companion object { companion object {
@@ -88,92 +93,20 @@ internal abstract class WhatsNewContent {
} }
} }
abstract fun getRequest(dataContext: DataContext?): HTMLEditorProvider.Request abstract suspend fun show(
abstract fun getActionWhiteList(): Set<String> project: Project,
dataContext: DataContext?,
triggeredByUser: Boolean,
reactionChecker: FUSReactionChecker,
)
abstract fun getVersion(): ContentVersion? abstract fun getVersion(): ContentVersion?
abstract suspend fun isAvailable(): Boolean abstract suspend fun isAvailable(): Boolean
protected fun getHandler(dataContext: DataContext?): JsQueryHandler? {
dataContext ?: return null
return object : JsQueryHandler {
override suspend fun query(id: Long, request: String): String {
val contains = getActionWhiteList().contains(request)
if (!contains) {
logger.trace { "EapWhatsNew action $request not allowed" }
WhatsNewCounterUsageCollector.actionNotAllowed(dataContext.project, request)
return "false"
}
if (request.isNotEmpty()) {
service<ActionManager>().getAction(request)?.let {
withContext(Dispatchers.EDT) {
it.actionPerformed(
AnActionEvent.createEvent(
it,
dataContext,
/*presentation =*/ null,
WhatsNewAction.PLACE,
ActionUiKind.NONE,
/*event =*/ null
)
)
logger.trace { "EapWhatsNew action $request performed" }
WhatsNewCounterUsageCollector.actionPerformed(dataContext.project, request)
}
return "true"
} ?: run {
logger.trace { "EapWhatsNew action $request not found" }
WhatsNewCounterUsageCollector.actionNotFound(dataContext.project, request)
}
}
return "false"
}
}
}
} }
internal class WhatsNewUrlContent(val url: String) : WhatsNewContent() { internal class WhatsNewUrlContent(val url: String) : WhatsNewContent() {
companion object { companion object {
val LOG = logger<WhatsNewUrlContent>() val LOG = logger<WhatsNewUrlContent>()
private val actionWhiteList = mutableSetOf("SearchEverywhere", "ChangeLaf", "ChangeIdeScale",
"SettingsSyncOpenSettingsAction", "BuildWholeSolutionAction",
"GitLab.Open.Settings.Page",
"AIAssistant.ToolWindow.ShowOrFocus", "ChangeMainToolbarColor",
"ShowEapDiagram", "multilaunch.RunMultipleProjects",
"EfCore.Shared.OpenQuickEfCoreActionsAction",
"OpenNewTerminalEAP", "CollectionsVisualizerEAP", "ShowDebugMonitoringToolEAP",
"LearnMoreStickyScrollEAP", "NewRiderProject", "BlazorHotReloadEAP")
}
override fun getRequest(dataContext: DataContext?): HTMLEditorProvider.Request {
val parameters = HashMap<String, String>()
parameters["var"] = "embed"
if (StartupUiUtil.isDarkTheme) {
parameters["theme"] = "dark"
}
parameters["lang"] = getCurrentLanguageTag()
val request = url(newFromEncoded(url).addParameters(parameters).toExternalForm())
try {
WhatsNewAction::class.java.getResourceAsStream("whatsNewTimeoutText.html").use { stream ->
if (stream != null) {
request.withTimeoutHtml(String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("__THEME__",
if (StartupUiUtil.isDarkTheme) "theme-dark" else "")
.replace("__TITLE__", IdeBundle.message("whats.new.timeout.title"))
.replace("__MESSAGE__", IdeBundle.message("whats.new.timeout.message"))
.replace("__ACTION__", IdeBundle.message("whats.new.timeout.action", url)))
}
}
}
catch (e: IOException) {
LOG.error(e)
}
request.withQueryHandler(getHandler(dataContext))
return request
}
override fun getActionWhiteList(): Set<String> {
return actionWhiteList
} }
override fun getVersion(): ContentVersion? = null override fun getVersion(): ContentVersion? = null
@@ -191,7 +124,7 @@ internal class WhatsNewUrlContent(val url: String) : WhatsNewContent() {
connection.instanceFollowRedirects = false connection.instanceFollowRedirects = false
connection.connect() connection.connect()
if (connection.responseCode != 200) { if (connection.responseCode >= 400) {
LOG.warn("WhatsNew page '$url' not available response code: ${connection.responseCode}") LOG.warn("WhatsNew page '$url' not available response code: ${connection.responseCode}")
false false
} }
@@ -205,11 +138,15 @@ internal class WhatsNewUrlContent(val url: String) : WhatsNewContent() {
} }
} }
} }
override suspend fun show(project: Project, dataContext: DataContext?, triggeredByUser: Boolean, reactionChecker: FUSReactionChecker) {
BrowserUtil.browse(IdeUrlTrackingParametersProvider.getInstance().augmentUrl(url))
}
} }
internal class WhatsNewVisionContent(page: WhatsNewInVisionContentProvider.Page) : WhatsNewContent() { internal class WhatsNewVisionContent(page: WhatsNewInVisionContentProvider.Page) : WhatsNewContent() {
companion object { companion object {
private const val THEME_KEY = "\$__VISION_PAGE_SETTINGS_THEME__\$" private const val THEME_KEY = "\$__VISION_PAGE_SETTINGS_THEME__$"
private const val DARK_THEME = "dark" private const val DARK_THEME = "dark"
private const val LIGHT_THEME = "light" private const val LIGHT_THEME = "light"
@@ -233,16 +170,12 @@ internal class WhatsNewVisionContent(page: WhatsNewInVisionContentProvider.Page)
contentHash = DigestUtil.sha1Hex(page.html) contentHash = DigestUtil.sha1Hex(page.html)
} }
override fun getRequest(dataContext: DataContext?): HTMLEditorProvider.Request { private fun getRequest(dataContext: DataContext?): HTMLEditorProvider.Request {
val request = html(content) val request = html(content)
request.withQueryHandler(getHandler(dataContext)) request.withQueryHandler(getHandler(dataContext))
return request return request
} }
override fun getActionWhiteList(): Set<String> {
return myActionWhiteList
}
override fun getVersion(): ContentVersion { override fun getVersion(): ContentVersion {
val buildNumber = ApplicationInfo.getInstance().getBuild() val buildNumber = ApplicationInfo.getInstance().getBuild()
return ContentVersion( return ContentVersion(
@@ -256,6 +189,74 @@ internal class WhatsNewVisionContent(page: WhatsNewInVisionContentProvider.Page)
override suspend fun isAvailable(): Boolean { override suspend fun isAvailable(): Boolean {
return true return true
} }
private fun getHandler(dataContext: DataContext?): JsQueryHandler? {
dataContext ?: return null
return object : JsQueryHandler {
override suspend fun query(id: Long, request: String): String {
val contains = myActionWhiteList.contains(request)
if (!contains) {
logger.trace { "EapWhatsNew action $request not allowed" }
WhatsNewCounterUsageCollector.actionNotAllowed(dataContext.project, request)
return "false"
}
if (request.isNotEmpty()) {
service<ActionManager>().getAction(request)?.let {
withContext(Dispatchers.EDT) {
it.actionPerformed(
AnActionEvent.createEvent(
it,
dataContext,
/*presentation =*/ null,
PLACE,
ActionUiKind.NONE,
/*event =*/ null
)
)
logger.trace { "EapWhatsNew action $request performed" }
WhatsNewCounterUsageCollector.actionPerformed(dataContext.project, request)
}
return "true"
} ?: run {
logger.trace { "EapWhatsNew action $request not found" }
WhatsNewCounterUsageCollector.actionNotFound(dataContext.project, request)
}
}
return "false"
}
}
}
override suspend fun show(project: Project, dataContext: DataContext?, triggeredByUser: Boolean, reactionChecker: FUSReactionChecker) {
if (!JBCefApp.isSupported()) {
logger.error("EapWhatsNew: can't be shown. JBCefApp isn't supported")
}
val title = IdeBundle.message("update.whats.new", ApplicationNamesInfo.getInstance().fullProductName)
withContext(Dispatchers.EDT) {
logger.info("Opening What's New in editor.")
val editor = writeIntentReadAction { openEditor(project, title, getRequest(dataContext)) }
editor?.let {
project.serviceAsync<FileEditorManager>().addTopComponent(it, ReactionsPanel.createPanel(PLACE, reactionChecker))
WhatsNewCounterUsageCollector.openedPerformed(project, triggeredByUser)
WhatsNewContentVersionChecker.saveLastShownContent(this@WhatsNewVisionContent)
val disposable = Disposer.newDisposable(project)
val busConnection = application.messageBus.connect(disposable)
busConnection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener {
override fun fileClosed(source: FileEditorManager, file: VirtualFile) {
if (it.file == file) {
WhatsNewCounterUsageCollector.closedPerformed(project)
Disposer.dispose(disposable)
}
}
})
}
}
}
} }
fun getCurrentLanguageTag(): String { fun getCurrentLanguageTag(): String {
@@ -265,4 +266,7 @@ fun getCurrentLanguageTag(): String {
} }
} }
private val DataContext.project: Project?
get() = CommonDataKeys.PROJECT.getData(this)
private val logger = logger<WhatsNewContent>() private val logger = logger<WhatsNewContent>()

View File

@@ -1,4 +1,4 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.whatsNew package com.intellij.platform.whatsNew
import com.intellij.ide.util.PropertiesComponent import com.intellij.ide.util.PropertiesComponent
@@ -12,6 +12,11 @@ internal class WhatsNewContentVersionChecker {
fun isNeedToShowContent(whatsNewContent: WhatsNewContent): Boolean { fun isNeedToShowContent(whatsNewContent: WhatsNewContent): Boolean {
val newVersion = whatsNewContent.getVersion() ?: run {
LOG.info("What's New content provider returns null version. What's New will be ignored.")
return false
}
val savedVersionInfo = PropertiesComponent.getInstance().getValue(LAST_SHOWN_EAP_VERSION_PROP) ?: run { val savedVersionInfo = PropertiesComponent.getInstance().getValue(LAST_SHOWN_EAP_VERSION_PROP) ?: run {
LOG.info("$LAST_SHOWN_EAP_VERSION_PROP is not defined. Will show What's New.") LOG.info("$LAST_SHOWN_EAP_VERSION_PROP is not defined. Will show What's New.")
return true return true
@@ -21,11 +26,6 @@ internal class WhatsNewContentVersionChecker {
return true return true
} }
val newVersion = whatsNewContent.getVersion() ?: run {
LOG.warn("What's New content provider returns null version. What's New will be ignored.")
return false
}
val result = shouldShowWhatsNew(savedVersion, newVersion) val result = shouldShowWhatsNew(savedVersion, newVersion)
LOG.info("Comparing versions $newVersion and $savedVersion: $result.") LOG.info("Comparing versions $newVersion and $savedVersion: $result.")
return result return result

View File

@@ -1,8 +1,9 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.whatsNew package com.intellij.platform.whatsNew
import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.platform.whatsNew.reaction.FUSReactionChecker
import com.intellij.testFramework.junit5.TestApplication import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.projectFixture import com.intellij.testFramework.junit5.fixture.projectFixture
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -17,8 +18,9 @@ class OnStartCheckServiceTest {
private val project = projectFixture() private val project = projectFixture()
private val mockContent = object : WhatsNewContent() { private val mockContent = object : WhatsNewContent() {
override fun getRequest(dataContext: DataContext?) = error("Mock object, do not call") override suspend fun show(project: Project, dataContext: DataContext?, triggeredByUser: Boolean, reactionChecker: FUSReactionChecker) {
override fun getActionWhiteList() = error("Mock object, do not call") error("Mock object, do not call")
}
override fun getVersion() = error("Mock object, do not call") override fun getVersion() = error("Mock object, do not call")
override suspend fun isAvailable() = error("Mock object, do not call") override suspend fun isAvailable() = error("Mock object, do not call")
} }