(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
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.DataContext
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.serviceAsync
import com.intellij.openapi.diagnostic.logger
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.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.ReactionsPanel
import com.intellij.ui.jcef.JBCefApp
import com.intellij.util.application
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import org.jetbrains.concurrency.await
internal class WhatsNewAction : AnAction(), com.intellij.openapi.project.DumbAware {
@@ -57,44 +48,13 @@ internal class WhatsNewAction : AnAction(), com.intellij.openapi.project.DumbAwa
openWhatsNewPage(project, dataContext)
}
private suspend fun openWhatsNewPage(project: Project, dataContext: DataContext?, byClient: Boolean = false) {
if (JBCefApp.isSupported()) {
val content = contentAsync.await()
if (content != null && content.isAvailable()) {
openContent(project, content, dataContext, byClient)
}
else {
LOG.warn("EapWhatsNew: can't be shown. Content not available")
}
private suspend fun openWhatsNewPage(project: Project, dataContext: DataContext?, triggeredByUser: Boolean = false) {
val content = contentAsync.await()
if (content != null && content.isAvailable()) {
content.show(project, dataContext, triggeredByUser, reactionChecker)
}
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)
}
}
})
}
LOG.warn("EapWhatsNew: can't be shown. Content not available")
}
}

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.
package com.intellij.platform.whatsNew
import com.intellij.ide.BrowserUtil
import com.intellij.ide.IdeBundle
import com.intellij.l10n.LocalizationStateService
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.*
import com.intellij.openapi.components.service
import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.diagnostic.logger
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.Companion.openEditor
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.url
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.Version
import com.intellij.openapi.vfs.VirtualFile
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.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.ui.StartupUiUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
internal abstract class WhatsNewContent {
companion object {
private val DataContext.project: Project?
get() = CommonDataKeys.PROJECT.getData(this)
suspend fun getWhatsNewContent(): WhatsNewContent? {
return if (WhatsNewInVisionContentProvider.getInstance().isAvailable()) {
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> {
companion object {
@@ -88,92 +93,20 @@ internal abstract class WhatsNewContent {
}
}
abstract fun getRequest(dataContext: DataContext?): HTMLEditorProvider.Request
abstract fun getActionWhiteList(): Set<String>
abstract suspend fun show(
project: Project,
dataContext: DataContext?,
triggeredByUser: Boolean,
reactionChecker: FUSReactionChecker,
)
abstract fun getVersion(): ContentVersion?
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() {
companion object {
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
@@ -191,7 +124,7 @@ internal class WhatsNewUrlContent(val url: String) : WhatsNewContent() {
connection.instanceFollowRedirects = false
connection.connect()
if (connection.responseCode != 200) {
if (connection.responseCode >= 400) {
LOG.warn("WhatsNew page '$url' not available response code: ${connection.responseCode}")
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() {
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 LIGHT_THEME = "light"
@@ -233,16 +170,12 @@ internal class WhatsNewVisionContent(page: WhatsNewInVisionContentProvider.Page)
contentHash = DigestUtil.sha1Hex(page.html)
}
override fun getRequest(dataContext: DataContext?): HTMLEditorProvider.Request {
private fun getRequest(dataContext: DataContext?): HTMLEditorProvider.Request {
val request = html(content)
request.withQueryHandler(getHandler(dataContext))
return request
}
override fun getActionWhiteList(): Set<String> {
return myActionWhiteList
}
override fun getVersion(): ContentVersion {
val buildNumber = ApplicationInfo.getInstance().getBuild()
return ContentVersion(
@@ -256,6 +189,74 @@ internal class WhatsNewVisionContent(page: WhatsNewInVisionContentProvider.Page)
override suspend fun isAvailable(): Boolean {
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 {
@@ -265,4 +266,7 @@ fun getCurrentLanguageTag(): String {
}
}
private val DataContext.project: Project?
get() = CommonDataKeys.PROJECT.getData(this)
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
import com.intellij.ide.util.PropertiesComponent
@@ -12,6 +12,11 @@ internal class WhatsNewContentVersionChecker {
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 {
LOG.info("$LAST_SHOWN_EAP_VERSION_PROP is not defined. Will show What's New.")
return true
@@ -21,11 +26,6 @@ internal class WhatsNewContentVersionChecker {
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)
LOG.info("Comparing versions $newVersion and $savedVersion: $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
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.project.Project
import com.intellij.platform.whatsNew.reaction.FUSReactionChecker
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.projectFixture
import kotlinx.coroutines.runBlocking
@@ -17,8 +18,9 @@ class OnStartCheckServiceTest {
private val project = projectFixture()
private val mockContent = object : WhatsNewContent() {
override fun getRequest(dataContext: DataContext?) = error("Mock object, do not call")
override fun getActionWhiteList() = error("Mock object, do not call")
override suspend fun show(project: Project, dataContext: DataContext?, triggeredByUser: Boolean, reactionChecker: FUSReactionChecker) {
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")
}