From ce5999a4afa81345cc3261995fdcf4bdacf1e1dc Mon Sep 17 00:00:00 2001 From: Vladimir Koshelev Date: Fri, 3 May 2024 16:21:12 +0000 Subject: [PATCH] [PY-64403] move WhatsNew from rider to a platform module Merge-request: IJ-MR-131247 Merged-by: Vladimir Koshelev GitOrigin-RevId: 73c9a19763d4f770d73da9223abd41368aca6a1d --- .idea/modules.xml | 1 + .../whatsNew/intellij.platform.whatsNew.iml | 19 ++ .../resources/intellij.platform.whatsNew.xml | 12 + .../messages/WhatsNewBundle.properties | 4 + platform/whatsNew/src/WhatsNewAction.kt | 280 ++++++++++++++++++ platform/whatsNew/src/WhatsNewBundle.kt | 19 ++ .../src/WhatsNewContentVersionChecker.kt | 74 +++++ .../src/WhatsNewShowOnStartCheckService.kt | 131 ++++++++ .../src/reaction/FUSReactionChecker.kt | 53 ++++ .../src/reaction/ReactionCollector.kt | 37 +++ .../whatsNew/src/reaction/ReactionsPanel.kt | 131 ++++++++ .../pycharm/PyCharmCommunityProperties.kt | 1 + .../intellij.pycharm.community.ide.impl.iml | 1 + 13 files changed, 763 insertions(+) create mode 100644 platform/whatsNew/intellij.platform.whatsNew.iml create mode 100644 platform/whatsNew/resources/intellij.platform.whatsNew.xml create mode 100644 platform/whatsNew/resources/messages/WhatsNewBundle.properties create mode 100644 platform/whatsNew/src/WhatsNewAction.kt create mode 100644 platform/whatsNew/src/WhatsNewBundle.kt create mode 100644 platform/whatsNew/src/WhatsNewContentVersionChecker.kt create mode 100644 platform/whatsNew/src/WhatsNewShowOnStartCheckService.kt create mode 100644 platform/whatsNew/src/reaction/FUSReactionChecker.kt create mode 100644 platform/whatsNew/src/reaction/ReactionCollector.kt create mode 100644 platform/whatsNew/src/reaction/ReactionsPanel.kt diff --git a/.idea/modules.xml b/.idea/modules.xml index e40efc7a049c..3735bfffd832 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -1125,6 +1125,7 @@ + diff --git a/platform/whatsNew/intellij.platform.whatsNew.iml b/platform/whatsNew/intellij.platform.whatsNew.iml new file mode 100644 index 000000000000..211df53dd561 --- /dev/null +++ b/platform/whatsNew/intellij.platform.whatsNew.iml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/platform/whatsNew/resources/intellij.platform.whatsNew.xml b/platform/whatsNew/resources/intellij.platform.whatsNew.xml new file mode 100644 index 000000000000..dbb6a50d3acf --- /dev/null +++ b/platform/whatsNew/resources/intellij.platform.whatsNew.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/platform/whatsNew/resources/messages/WhatsNewBundle.properties b/platform/whatsNew/resources/messages/WhatsNewBundle.properties new file mode 100644 index 000000000000..fed92c783e4c --- /dev/null +++ b/platform/whatsNew/resources/messages/WhatsNewBundle.properties @@ -0,0 +1,4 @@ +EapWhatsNewAction.text=What's _New +EapWhatsNewAction.description=Find out about the new features in this version of the IDE + +useful.pane.text=Do you find this page useful? \ No newline at end of file diff --git a/platform/whatsNew/src/WhatsNewAction.kt b/platform/whatsNew/src/WhatsNewAction.kt new file mode 100644 index 000000000000..5b7f1aa88237 --- /dev/null +++ b/platform/whatsNew/src/WhatsNewAction.kt @@ -0,0 +1,280 @@ +// Copyright 2000-2024 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 +import com.intellij.ide.IdeBundle +import com.intellij.ide.actions.WhatsNewAction +import com.intellij.internal.statistic.collectors.fus.actions.persistence.ActionRuleValidator +import com.intellij.internal.statistic.eventLog.EventLogGroup +import com.intellij.internal.statistic.eventLog.events.EventFields +import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector +import com.intellij.openapi.actionSystem.* +import com.intellij.openapi.application.ApplicationNamesInfo +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ex.ApplicationInfoEx +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.diagnostic.logger +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.fileEditor.impl.HTMLEditorProvider.JsQueryHandler +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.registry.Registry +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.whatsNew.reaction.FUSReactionChecker +import com.intellij.platform.whatsNew.reaction.ReactionsPanel +import com.intellij.ui.jcef.JBCefApp +import com.intellij.util.Urls.newFromEncoded +import com.intellij.util.application +import com.intellij.util.ui.StartupUiUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.util.* +import kotlin.let +import kotlin.run +import kotlin.text.isNotEmpty + +class WhatsNewAction : AnAction(), com.intellij.openapi.project.DumbAware { + companion object { + private val DataContext.project: Project? + get() = CommonDataKeys.PROJECT.getData(this) + + private val LOG = logger() + + private const val PLACE = "WhatsNew" + private const val TEST_KEY = "whats.new.test.mode" + private const val REACTIONS_STATE = "whatsnew.reactions.state" + + + private val actionWhiteList = listOf("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") + + private fun getHandler(dataContext: DataContext?): JsQueryHandler? { + dataContext ?: return null + + return object : JsQueryHandler { + override suspend fun query(id: Long, request: String): String { + val contains = actionWhiteList.contains(request) + if (!contains) { + if (LOG.isTraceEnabled) { + LOG.trace("EapWhatsNew action $request not allowed") + } + WhatsNewCounterUsageCollector.actionNotAllowed(dataContext.project, request) + return "false" + } + + if (request.isNotEmpty()) { + ActionManager.getInstance().getAction(request)?.let { + withContext(Dispatchers.EDT) { + it.actionPerformed(AnActionEvent.createFromAnAction(it, null, PLACE, dataContext)) + if (LOG.isTraceEnabled) { + LOG.trace("EapWhatsNew action $request performed") + } + WhatsNewCounterUsageCollector.actionPerformed(dataContext.project, request) + } + return "true" + } ?: run { + if (LOG.isTraceEnabled) { + LOG.trace("EapWhatsNew action $request not found") + } + WhatsNewCounterUsageCollector.actionNotFound(dataContext.project, request) + } + } + return "false" + } + } + } + + private val reactionChecker = FUSReactionChecker(REACTIONS_STATE) + + fun refresh() { + reactionChecker.clearLikenessState() + if (LOG.isTraceEnabled) { + LOG.trace("EapWhatsNew reaction refresh") + } + } + + private val isEap: Boolean + get() = if (Registry.`is`(TEST_KEY)) true else ApplicationInfoEx.getInstanceEx().isEAP + + fun openWhatsNew(project: Project) { + if (!isEap) { + openWhatsNewPage(project) + if (LOG.isTraceEnabled) { + LOG.trace("EapWhatsNew: it's not EAP version") + } + return + } + + val dataContextFromFocusAsync = DataManager.getInstance().dataContextFromFocusAsync + if (dataContextFromFocusAsync.isSucceeded) { + dataContextFromFocusAsync.onSuccess { dataContext -> + val queryHandler = getHandler(dataContext) + openWhatsNewPage(project, false, queryHandler) + }.onError { + openWhatsNewPage(project) + } + return + } + openWhatsNewPage(project) + } + + private fun openWhatsNewPage(project: Project, url: String, byClient: Boolean = false, queryHandler: JsQueryHandler?) { + check(JBCefApp.isSupported()) { "JCEF is not supported on this system" } + val parameters = HashMap() + parameters["var"] = "embed" + if (StartupUiUtil.isDarkTheme) { + parameters["theme"] = "dark" + } + val locale = Locale.getDefault() + if (locale != null) { + parameters["lang"] = locale.toLanguageTag().lowercase() + } + 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) { + Logger.getInstance(WhatsNewAction::class.java).error(e) + } + request.withQueryHandler(queryHandler) + val title = IdeBundle.message("update.whats.new", ApplicationNamesInfo.getInstance().fullProductName) + + openEditor(project, title, request)?.let { + FileEditorManager.getInstance(project).addTopComponent(it, ReactionsPanel.createPanel(PLACE, reactionChecker)) + WhatsNewCounterUsageCollector.openedPerformed(project, byClient) + + WhatsNewContentVersionChecker.saveLastShownUrl(url) + + 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) + } + } + }) + } + } + + private fun openWhatsNewPage(project: Project?, byClient: Boolean = false, queryHandler: JsQueryHandler? = null) { + val whatsNewUrl = WhatsNewContentVersionChecker.getUrl() ?: return + + if (LOG.isTraceEnabled) { + LOG.trace("EapWhatsNew: openWhatsNewPage. queryHandler ${if (queryHandler != null) "enabled" else "disabled"}") + } + + if (project != null && JBCefApp.isSupported()) { + openWhatsNewPage(project, whatsNewUrl, byClient, queryHandler) + } + else { + if (LOG.isTraceEnabled) { + LOG.trace("EapWhatsNew: can't be shown. JBCefApp isn't supported") + } + } + } + } + + init { + templatePresentation.text = WhatsNewBundle.message("EapWhatsNewAction.text") + templatePresentation.description = WhatsNewBundle.message("EapWhatsNewAction.description") + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + + override fun update(e: AnActionEvent) { + val available = WhatsNewContentVersionChecker.getUrl() != null + e.presentation.isEnabledAndVisible = available + if (available) { + e.presentation.setText(IdeBundle.messagePointer("whats.new.action.custom.text", ApplicationNamesInfo.getInstance().fullProductName)) + e.presentation.setDescription( + IdeBundle.messagePointer("whats.new.action.custom.description", ApplicationNamesInfo.getInstance().fullProductName)) + } + } + + override fun actionPerformed(e: AnActionEvent) { + openWhatsNewPage(e.project, true, if (isEap) getHandler(e.dataContext) else null) + } +} + +internal enum class OpenedType { Auto, ByClient } + +@Suppress("EnumEntryName") +internal enum class ActionFailedReason { Not_Allowed, Not_Found } + + +@Suppress("CompanionObjectInExtension") +internal object WhatsNewCounterUsageCollector : CounterUsagesCollector() { + private val eventLogGroup: EventLogGroup = EventLogGroup("whatsnew", 1) + + private val opened = eventLogGroup.registerEvent("tab_opened", EventFields.Enum(("type"), OpenedType::class.java)) + private val closed = eventLogGroup.registerEvent("tab_closed") + private val actionId = EventFields.StringValidatedByCustomRule("action_id", ActionRuleValidator::class.java) + private val perform = eventLogGroup.registerEvent("action_performed", actionId) + private val failed = eventLogGroup.registerEvent("action_failed", actionId, EventFields.Enum(("type"), ActionFailedReason::class.java)) + + fun openedPerformed(project: Project?, byClient: Boolean) { + opened.log(project, if (byClient) OpenedType.ByClient else OpenedType.Auto) + LegacyRiderWhatsNewCounterUsagesCollector.opened.log(project, if (byClient) OpenedType.ByClient else OpenedType.Auto) + } + + fun closedPerformed(project: Project?) { + closed.log(project) + LegacyRiderWhatsNewCounterUsagesCollector.closed.log(project) + } + + fun actionPerformed(project: Project?, id: String) { + perform.log(project, id) + LegacyRiderWhatsNewCounterUsagesCollector.perform.log(project, id) + } + + fun actionNotAllowed(project: Project?, id: String) { + failed.log(project, id, ActionFailedReason.Not_Allowed) + LegacyRiderWhatsNewCounterUsagesCollector.failed.log(project, id, ActionFailedReason.Not_Allowed) + } + + fun actionNotFound(project: Project?, id: String) { + failed.log(project, id, ActionFailedReason.Not_Found) + LegacyRiderWhatsNewCounterUsagesCollector.failed.log(project, id, ActionFailedReason.Not_Found) + } + + override fun getGroup(): EventLogGroup { + return eventLogGroup + } +} + +internal object LegacyRiderWhatsNewCounterUsagesCollector : CounterUsagesCollector() { + private val eventLogGroup: EventLogGroup = EventLogGroup("rider.whatsnew.eap", 3) + + internal val opened = eventLogGroup.registerEvent("tab_opened", EventFields.Enum(("type"), OpenedType::class.java)) + internal val closed = eventLogGroup.registerEvent("tab_closed") + internal val actionId = EventFields.StringValidatedByCustomRule("action_id", ActionRuleValidator::class.java) + internal val perform = eventLogGroup.registerEvent("action_performed", actionId) + internal val failed = eventLogGroup.registerEvent("action_failed", actionId, EventFields.Enum(("type"), ActionFailedReason::class.java)) + + override fun getGroup(): EventLogGroup { + return eventLogGroup + } +} diff --git a/platform/whatsNew/src/WhatsNewBundle.kt b/platform/whatsNew/src/WhatsNewBundle.kt new file mode 100644 index 000000000000..2da056a00653 --- /dev/null +++ b/platform/whatsNew/src/WhatsNewBundle.kt @@ -0,0 +1,19 @@ +// Copyright 2000-2024 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.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.PropertyKey + +object WhatsNewBundle { + private const val pathToBundle = "messages.WhatsNewBundle" + private val bundle by lazy { DynamicBundle(WhatsNewBundle::class.java, pathToBundle); } + + @Nls + fun message( + @PropertyKey(resourceBundle = pathToBundle) key: String, + vararg params: Any + ): String { + return bundle.getMessage(key, *params) + } +} \ No newline at end of file diff --git a/platform/whatsNew/src/WhatsNewContentVersionChecker.kt b/platform/whatsNew/src/WhatsNewContentVersionChecker.kt new file mode 100644 index 000000000000..fd415c05a8d3 --- /dev/null +++ b/platform/whatsNew/src/WhatsNewContentVersionChecker.kt @@ -0,0 +1,74 @@ +package com.intellij.platform.whatsNew + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.diagnostic.logger +import com.intellij.platform.ide.customization.ExternalProductResourceUrls + +class WhatsNewContentVersionChecker { + companion object { + private val LOG = logger() + private const val LAST_SHOWN_EAP_URL_PROP = "whats.new.last.shown.url" + private val linkRegEx = "^https://www\\.jetbrains\\.com/[a-zA-Z]+/whatsnew(-eap)?/(\\d+)-(\\d+)-(\\d+)/$".toRegex() + + fun getUrl(): String? { + return ExternalProductResourceUrls.getInstance().whatIsNewPageUrl?.toDecodedForm() + } + + fun productVersion(): ContentVersion? { + return try { + val year = ApplicationInfo.getInstance().majorVersion.toInt() + val release = ApplicationInfo.getInstance().minorVersion.toInt() + ContentVersion(year, release, 0) + } catch (e: NumberFormatException) { + LOG.warn("WhatsNew: unknown productVersion '$e'") + null + } + } + + fun lastShownLinkVersion(): ContentVersion? { + return PropertiesComponent.getInstance().getValue(LAST_SHOWN_EAP_URL_PROP)?.let { + return parseUrl(it) ?: run { + if (LOG.isTraceEnabled) { + LOG.trace("WhatsNew: unknown lastShownLinkVersion: '$it'") + } + null + } + } + } + + fun linkVersion(): ContentVersion? { + val url = getUrl() + if (url != null) { + return parseUrl(url) + } + return null + } + + fun saveLastShownUrl(url: String) { + if (LOG.isTraceEnabled) { + LOG.trace("EapWhatsNew URL saved '$url'") + } + PropertiesComponent.getInstance().setValue(LAST_SHOWN_EAP_URL_PROP, url) + } + + + private fun parseUrl(link: String): ContentVersion? { + linkRegEx.matchEntire(link)?.let { + val year = it.groups[it.groups.size - 3]?.value?.toInt() ?: return@let null + val release = it.groups[it.groups.size - 2]?.value?.toInt() ?: return@let null + val eap = it.groups[it.groups.size - 1]?.value?.toInt() ?: return@let null + + return ContentVersion(year, release, eap) + } ?: run { + if (LOG.isTraceEnabled) { + LOG.trace("EapWhatsNew: incompatible link '$link'") + } + } + return null + } + + data class ContentVersion(val year: Int, val release: Int, val eap: Int) + + } +} \ No newline at end of file diff --git a/platform/whatsNew/src/WhatsNewShowOnStartCheckService.kt b/platform/whatsNew/src/WhatsNewShowOnStartCheckService.kt new file mode 100644 index 000000000000..c58a1093de3b --- /dev/null +++ b/platform/whatsNew/src/WhatsNewShowOnStartCheckService.kt @@ -0,0 +1,131 @@ +package com.intellij.platform.whatsNew + +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.openapi.util.registry.Registry +import com.intellij.util.SystemProperties +import com.intellij.util.application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean + +class WhatsNewShowOnStartCheckService : ProjectActivity { + companion object { + private val LOG = logger() + } + + private val ourStarted = AtomicBoolean(false) + private val isPlaybackMode = SystemProperties.getBooleanProperty("idea.is.playback", false) + + private suspend fun checkConnectionAvailable(): Boolean { + return withContext(Dispatchers.IO) { + return@withContext try { + val url = WhatsNewContentVersionChecker.getUrl()?.let { URL(it) } ?: return@withContext false + val connection = url.openConnection() as HttpURLConnection + + connection.setConnectTimeout(5000) + connection.instanceFollowRedirects = false + + connection.connect() + if (connection.responseCode != 200) { + LOG.warn("WhatsNew page '$url' not available response code: ${connection.responseCode}") + false + } + else { + true + } + } + catch (e: Exception) { + LOG.warn("WhatsNew page connection error: '$e") + false + } + } + } + + override suspend fun execute(project: Project) { + if (ourStarted.getAndSet(true)) return + if (application.isHeadlessEnvironment || application.isUnitTestMode || isPlaybackMode) return + + withContext(Dispatchers.EDT) { + if (!Registry.`is`("whats.new.enabled")) { + if(LOG.isTraceEnabled){ + LOG.trace("EapWhatsNew: DISABLED") + } + + return@withContext + } + + val isTestMode = Registry.`is`("whats.new.test.mode") + + if(isTestMode) { + if(LOG.isTraceEnabled){ + LOG.trace("WhatsNew: TEST MODE STARTED") + } + replaceAction() + openWhatsNew(project) + return@withContext + } + + val productVersion = WhatsNewContentVersionChecker.productVersion() ?: run { + if(LOG.isTraceEnabled) { + LOG.trace("WhatsNew: unknown current version") + } + return@withContext + } + + val linkVersion = WhatsNewContentVersionChecker.linkVersion() ?: run { + if(LOG.isTraceEnabled) { + LOG.trace("WhatsNew: unknown link version") + } + unregisterAction() + return@withContext + } + + if(productVersion.year == linkVersion.year && productVersion.release == linkVersion.release) { + LOG.trace("WhatsNew: productVersion '$productVersion' linkVersion: '$linkVersion' ") + replaceAction() + + WhatsNewContentVersionChecker.lastShownLinkVersion()?.let { + LOG.trace("WhatsNew: link version last: '$it' new: '$linkVersion' ") + if(it.eap < linkVersion.eap) { + openWhatsNew(project) + return@withContext + } + } ?: run { + LOG.trace("WhatsNew: link not saved") + openWhatsNew(project) + } + } else { + LOG.trace("WhatsNew: link '${WhatsNewContentVersionChecker.getUrl()}' incompatible with this product version: $productVersion ") + unregisterAction() + } + } + } + + private suspend fun openWhatsNew(project: Project) { + if(checkConnectionAvailable()) { + WhatsNewAction.openWhatsNew(project) + } + } + + private fun replaceAction() { + val actionManager = ActionManager.getInstance() + actionManager.replaceAction("WhatsNewAction", WhatsNewAction()) + if(LOG.isTraceEnabled){ + LOG.trace("WhatsNew: WhatsNewAction replaced by WhatsNewAction") + } + } + + private fun unregisterAction() { + val actionManager = ActionManager.getInstance() + actionManager.unregisterAction("WhatsNewAction") + if(LOG.isTraceEnabled){ + LOG.trace("EapWhatsNew: WhatsNewAction unregister") + } + } +} \ No newline at end of file diff --git a/platform/whatsNew/src/reaction/FUSReactionChecker.kt b/platform/whatsNew/src/reaction/FUSReactionChecker.kt new file mode 100644 index 000000000000..db3fd6d8648d --- /dev/null +++ b/platform/whatsNew/src/reaction/FUSReactionChecker.kt @@ -0,0 +1,53 @@ +package com.intellij.platform.whatsNew.reaction + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project + +class FUSReactionChecker(private val stateKey: String): ReactionChecker { + override fun onLike(project: Project?, place: String?) { + ApplicationManager.getApplication().assertIsDispatchThread() + val value = if (getLikenessState() == ReactionChecker.State.Liked) { + 0 + } + else 1 + + putValue(value) + ReactionCollector.reactedPerformed(project, place, ReactionType.Like, + if (value == 0) ReationAction.Unset + else ReationAction.Set) + } + + override fun onDislike(project: Project?, place: String?) { + ApplicationManager.getApplication().assertIsDispatchThread() + val value = if (getLikenessState() == ReactionChecker.State.Disliked) { + 0 + } + else -1 + + putValue(value) + ReactionCollector.reactedPerformed(project, place, ReactionType.Dislike, + if (value == 0) ReationAction.Unset + else ReationAction.Set) + } + + internal fun putValue(value: Int) { + val propertiesComponent = PropertiesComponent.getInstance() + propertiesComponent.setValue(stateKey, value, 0) + } + + private fun getLikenessState(): ReactionChecker.State { + ApplicationManager.getApplication().assertIsDispatchThread() + val propertiesComponent = PropertiesComponent.getInstance() + + return ReactionChecker.State.stateByIndex(propertiesComponent.getInt(stateKey, 0)) + } + + override fun clearLikenessState() { + putValue(0) + } + + override fun checkState(state: ReactionChecker.State): Boolean { + return getLikenessState() == state + } +} \ No newline at end of file diff --git a/platform/whatsNew/src/reaction/ReactionCollector.kt b/platform/whatsNew/src/reaction/ReactionCollector.kt new file mode 100644 index 000000000000..6b81e28bb615 --- /dev/null +++ b/platform/whatsNew/src/reaction/ReactionCollector.kt @@ -0,0 +1,37 @@ +package com.intellij.platform.whatsNew.reaction + +import com.intellij.internal.statistic.eventLog.EventLogGroup +import com.intellij.internal.statistic.eventLog.events.EventFields +import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector +import com.intellij.openapi.project.Project + +internal enum class ReactionType { Like, Dislike } +internal enum class ReationAction { Set, Unset } + + +internal object ReactionCollector : CounterUsagesCollector() { + private val eventLogGroup: EventLogGroup = EventLogGroup("whatsnew.reactions", 1) + + private val reacted = eventLogGroup.registerEvent("reacted", + EventFields.ActionPlace, + EventFields.Enum(("type"), ReactionType::class.java), + EventFields.Enum("action", ReationAction::class.java)) + + fun reactedPerformed(project: Project?, place: String?, type: ReactionType, action: ReationAction) { + reacted.log(project, place, type, action) + LegacyReactionCollector.reacted.log(project, place, type, action) + } + + override fun getGroup(): EventLogGroup = eventLogGroup +} + +internal object LegacyReactionCollector : CounterUsagesCollector() { + private val eventLogGroup: EventLogGroup = EventLogGroup("rider.reactions", 2) + + internal val reacted = eventLogGroup.registerEvent("reacted", + EventFields.ActionPlace, + EventFields.Enum(("type"), ReactionType::class.java), + EventFields.Enum("action", ReationAction::class.java)) + + override fun getGroup(): EventLogGroup = eventLogGroup +} \ No newline at end of file diff --git a/platform/whatsNew/src/reaction/ReactionsPanel.kt b/platform/whatsNew/src/reaction/ReactionsPanel.kt new file mode 100644 index 000000000000..f9c006227bab --- /dev/null +++ b/platform/whatsNew/src/reaction/ReactionsPanel.kt @@ -0,0 +1,131 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.whatsNew.reaction + +import com.intellij.CommonBundle +import com.intellij.icons.AllIcons +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.* +import com.intellij.openapi.actionSystem.ex.ActionButtonLook +import com.intellij.openapi.actionSystem.impl.ActionToolbarImpl +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.NlsActions +import com.intellij.platform.whatsNew.WhatsNewBundle +import com.intellij.ui.JBColor +import net.miginfocom.swing.MigLayout +import org.jetbrains.annotations.ApiStatus +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel + +class ReactionsPanel { + companion object { + @ApiStatus.Internal + @JvmField + val STATE_CHECKER_KEY = DataKey.create("RunWidgetSlot") + + private val group: ActionGroup = DefaultActionGroup(mutableListOf(LikeReactionAction(), DislikeUsefulAction())) + + fun createPanel(place: String, + stateChecker: ReactionChecker): JComponent { + + return JPanel(MigLayout("ins 0, gap 7", "push[min!][pref!]push")).apply { + add(JLabel(WhatsNewBundle.message("useful.pane.text"))) + + DataManager.registerDataProvider(this) { key -> + if (STATE_CHECKER_KEY.`is`(key)) + stateChecker + else null + } + + val look = object : ActionButtonLook() {} + + val toolbar = object : ActionToolbarImpl(place, group, true) { + override fun getActionButtonLook(): ActionButtonLook { + return look + } + } + toolbar.border = null + toolbar.targetComponent = this + add(toolbar) + + // isOpaque = false + background = JBColor.WHITE + toolbar.isOpaque = false + } + } + } +} + + +private class LikeReactionAction() : ReactionAction(CommonBundle.message("button.without.mnemonic.yes"), AllIcons.Ide.LikeDimmed, + AllIcons.Ide.Like, + AllIcons.Ide.LikeSelected) { + override fun isSelected(e: AnActionEvent): Boolean { + return getReactionStateChecker(e)?.checkState(ReactionChecker.State.Liked) == true + } + + override fun actionPerformed(e: AnActionEvent) { + getReactionStateChecker(e)?.onLike(e.project, e.place) + } +} + +private class DislikeUsefulAction() : ReactionAction(CommonBundle.message("button.without.mnemonic.no"), AllIcons.Ide.DislikeDimmed, + AllIcons.Ide.Dislike, AllIcons.Ide.DislikeSelected) { + override fun isSelected(e: AnActionEvent): Boolean { + return getReactionStateChecker(e)?.checkState(ReactionChecker.State.Disliked) == true + } + + override fun actionPerformed(e: AnActionEvent) { + getReactionStateChecker(e)?.onDislike(e.project, e.place) + } + +} + +private abstract class ReactionAction(text: @NlsActions.ActionText String, + val icon: Icon, + val hoveredIcon: Icon, + val selectedIcon: Icon) : AnAction(text, null, icon), DumbAware { + + override fun update(e: AnActionEvent) { + val selected = isSelected(e) + val presentation = e.presentation + + presentation.icon = if (selected) selectedIcon else icon + presentation.hoveredIcon = if (selected) selectedIcon else hoveredIcon + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + + abstract fun isSelected(e: AnActionEvent): Boolean + + fun getReactionStateChecker(e: AnActionEvent): ReactionChecker? { + return e.dataContext.getData(ReactionsPanel.STATE_CHECKER_KEY) + } + +} + +interface ReactionChecker { + enum class State(val index: Int) { + Liked (1), + Disliked (-1), + Undefined (0); + + companion object { + fun stateByIndex(ind: Int?): State { + return ind?.let { values().firstOrNull { it.index == ind } ?: Undefined } ?: Undefined + } + } + } + + fun onLike(project: Project?, place: String?) + + fun onDislike(project: Project?, place: String?) + + fun checkState(state: State): Boolean + + fun clearLikenessState() +} \ No newline at end of file diff --git a/python/build/src/org/jetbrains/intellij/build/pycharm/PyCharmCommunityProperties.kt b/python/build/src/org/jetbrains/intellij/build/pycharm/PyCharmCommunityProperties.kt index 2b8eda096014..d65aa04292a3 100644 --- a/python/build/src/org/jetbrains/intellij/build/pycharm/PyCharmCommunityProperties.kt +++ b/python/build/src/org/jetbrains/intellij/build/pycharm/PyCharmCommunityProperties.kt @@ -28,6 +28,7 @@ class PyCharmCommunityProperties(private val communityHome: Path) : PyCharmPrope "intellij.xml.dom.impl", "intellij.platform.main", "intellij.pycharm.community", + "intellij.platform.whatsNew", ) productLayout.bundledPluginModules.addAll( listOf( diff --git a/python/ide/impl/intellij.pycharm.community.ide.impl.iml b/python/ide/impl/intellij.pycharm.community.ide.impl.iml index 367b088a7bd4..02fa1aaaca61 100644 --- a/python/ide/impl/intellij.pycharm.community.ide.impl.iml +++ b/python/ide/impl/intellij.pycharm.community.ide.impl.iml @@ -25,6 +25,7 @@ + \ No newline at end of file