mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
WEB-79407 markdown: offer Figma Connect on a Markdown file that links to Figma
A Markdown file whose text contains a figma.com file, design or proto URL draws the platform's editor banner with an offer to install Figma Connect. The offer is shown once per project and it is answered once per project. The new content module `intellij.markdown.figmaAdvertiser` registers `com.intellij.pluginSuggestionProvider`. It depends on no `intellij.figma.*` module, because that plugin loads only when it is installed, which is the one case this offer does not exist for. The Figma URL rule and the tool window id are therefore written again here, each with the reason in its KDoc. The URL rule is deliberately the broader of the two copies: `FigmaUrlDetector` asks which node to open and so needs a file key, while this asks whether the author works with Figma, which a keyless link still answers. The order of the questions follows the cost of answering them: the registry switch, the file extension, the per-project dismissal, then `buildSuggestionIfNeeded`, which drops a plugin id that is already loaded. The file's own text is read last, and the answer is kept on the `VirtualFile`, so a repaint reads it instead of the text. The offer's word follows whether the plugin is on disk, not whether it is switched off. The banner is drawn whenever the plugin is not loaded, and on disk, switched on and not loaded is an ordinary state: `PluginEnabler.HEADLESS` writes the flag and loads nothing, and every accepted plugin-advertiser offer runs it. Such a user would otherwise be offered an install and sent to Marketplace for a plugin they already have. The click asks the enabler in both on-disk states, because `DynamicPluginEnabler.enable` loads a descriptor that is switched on and not loaded rather than needing a restart. The restart offer is the platform's own, and nothing is added here. `installAndEnable` runs its success body through `InstallAndEnableTask.runOnSuccess`, which is given the result of `PluginInstaller.installAndLoadDynamicPlugin` — false when the plugin needs a restart. On that branch `PluginsAdvertiserDialogPluginInstaller` has already run `PluginManagerMain.notifyPluginsUpdated`, whose notification carries the "Restart to activate plugin updates" action. The Figma tool window, which is the setup wizard's entry point for a first-time user, is opened through a safe call that does nothing when the plugin did not load. `markdown.figma.advertiser` is a FUS group of its own, with a `trigger` field. `figma.connect` is declared in a module that is absent exactly when this code runs, and the platform's `plugins.advertiser` group has no field saying which trigger fired. Tests: `intellij.markdown.tests`, 20 tests over `MarkdownFigmaAdvertiserDecisionTest` and `MarkdownFigmaAdvertiserBannerTest`. Eleven mutation rows, 17 kills, every prediction matched. A UI test is added to `intellij.figma.uiTests` and is not run; the phase batches one TeamCity pass. Claude-Session: https://claude.ai/code/session_01KRDwfXtkThVhoM8EKVGeMC (cherry picked from commit 2f618d22f0c64a0ad6fb54da8008bf2bd1c34483) GitOrigin-RevId: 9d6d437048859fb33e986126cb7c1c6bd7c60c46
This commit is contained in:
committed by
intellij-monorepo-bot
parent
bf00345288
commit
d197caf807
Generated
+1
@@ -1141,6 +1141,7 @@
|
||||
<module fileurl="file://$PROJECT_DIR$/plugins/markdown/compose/intellij.markdown.compose.preview.iml" filepath="$PROJECT_DIR$/plugins/markdown/compose/intellij.markdown.compose.preview.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/plugins/markdown/fenceInjection/intellij.markdown.fenceInjection.iml" filepath="$PROJECT_DIR$/plugins/markdown/fenceInjection/intellij.markdown.fenceInjection.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/plugins/markdown/textMate/intellij.markdown.fenceInjector.textMate.iml" filepath="$PROJECT_DIR$/plugins/markdown/textMate/intellij.markdown.fenceInjector.textMate.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/plugins/markdown/figmaAdvertiser/intellij.markdown.figmaAdvertiser.iml" filepath="$PROJECT_DIR$/plugins/markdown/figmaAdvertiser/intellij.markdown.figmaAdvertiser.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/plugins/markdown/core/frontend/intellij.markdown.frontend.iml" filepath="$PROJECT_DIR$/plugins/markdown/core/frontend/intellij.markdown.frontend.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/plugins/markdown/frontmatter/intellij.markdown.frontmatter.iml" filepath="$PROJECT_DIR$/plugins/markdown/frontmatter/intellij.markdown.frontmatter.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/plugins/markdown/frontmatter/toml/intellij.markdown.frontmatter.toml.iml" filepath="$PROJECT_DIR$/plugins/markdown/frontmatter/toml/intellij.markdown.frontmatter.toml.iml" />
|
||||
|
||||
@@ -1415,6 +1415,7 @@ plugins/markdown/core
|
||||
plugins/markdown/core/backend
|
||||
plugins/markdown/core/frontend
|
||||
plugins/markdown/fenceInjection
|
||||
plugins/markdown/figmaAdvertiser
|
||||
plugins/markdown/frontmatter
|
||||
plugins/markdown/frontmatter/toml
|
||||
plugins/markdown/frontmatter/yaml
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
- name: lib/modules/intellij.markdown.fenceInjector.textMate.jar
|
||||
contentModules:
|
||||
- name: intellij.markdown.fenceInjector.textMate
|
||||
- name: lib/modules/intellij.markdown.figmaAdvertiser.jar
|
||||
contentModules:
|
||||
- name: intellij.markdown.figmaAdvertiser
|
||||
- name: lib/modules/intellij.markdown.frontend.jar
|
||||
contentModules:
|
||||
- name: intellij.markdown.frontend
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<module name="intellij.markdown.backend"/>
|
||||
<module name="intellij.markdown.java"/>
|
||||
<module name="intellij.markdown.fenceInjector.textMate"/>
|
||||
<module name="intellij.markdown.figmaAdvertiser"/>
|
||||
</content>
|
||||
|
||||
<description><![CDATA[
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
load("@rules_jvm//:jvm.bzl", "jvm_library")
|
||||
|
||||
### auto-generated section `build intellij.markdown.figmaAdvertiser` start
|
||||
|
||||
jvm_library(
|
||||
name = "figmaAdvertiser",
|
||||
srcs = glob(
|
||||
[
|
||||
"src/**/*.kt",
|
||||
"src/**/*.java",
|
||||
"src/**/*.form",
|
||||
],
|
||||
allow_empty = True,
|
||||
),
|
||||
module_name = "intellij.markdown.figmaAdvertiser",
|
||||
resource_strip_prefix = "resources",
|
||||
resources = glob(
|
||||
["resources/**/*"],
|
||||
),
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
# do not sort,
|
||||
"@lib//:kotlin-stdlib",
|
||||
"//platform/analysis-api:analysis",
|
||||
"//platform/core-api:core",
|
||||
"//platform/core-impl",
|
||||
"//platform/core-ui",
|
||||
"//platform/editor-ui-api:editor-ui",
|
||||
"//platform/platform-api:ide",
|
||||
"//platform/ide-core",
|
||||
"//platform/platform-impl:ide-impl",
|
||||
"//platform/statistics",
|
||||
"//platform/util",
|
||||
],
|
||||
)
|
||||
|
||||
jvm_library(
|
||||
name = "figmaAdvertiser_test_lib",
|
||||
testonly = True,
|
||||
srcs = glob(
|
||||
[],
|
||||
allow_empty = True,
|
||||
),
|
||||
module_name = "intellij.markdown.figmaAdvertiser",
|
||||
visibility = ["//visibility:public"],
|
||||
runtime_deps = [
|
||||
# do not sort,
|
||||
":figmaAdvertiser",
|
||||
"//platform/analysis-api:analysis_test_lib",
|
||||
"//platform/core-api:core_test_lib",
|
||||
"//platform/core-impl:core-impl_test_lib",
|
||||
"//platform/core-ui:core-ui_test_lib",
|
||||
"//platform/editor-ui-api:editor-ui_test_lib",
|
||||
"//platform/platform-api:ide_test_lib",
|
||||
"//platform/ide-core:ide-core_test_lib",
|
||||
"//platform/platform-impl:ide-impl_test_lib",
|
||||
"//platform/statistics:statistics_test_lib",
|
||||
"//platform/util:util_test_lib",
|
||||
],
|
||||
)
|
||||
### auto-generated section `build intellij.markdown.figmaAdvertiser` end
|
||||
|
||||
### auto-generated section `iml intellij.markdown.figmaAdvertiser` start
|
||||
exports_files(
|
||||
[
|
||||
"intellij.markdown.figmaAdvertiser.iml",
|
||||
],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
### auto-generated section `iml intellij.markdown.figmaAdvertiser` end
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="kotlin-stdlib" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.analysis" />
|
||||
<orderEntry type="module" module-name="intellij.platform.core" />
|
||||
<orderEntry type="module" module-name="intellij.platform.core.impl" />
|
||||
<orderEntry type="module" module-name="intellij.platform.core.ui" />
|
||||
<orderEntry type="module" module-name="intellij.platform.editor.ui" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide.core" />
|
||||
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
|
||||
<orderEntry type="module" module-name="intellij.platform.statistics" />
|
||||
<orderEntry type="module" module-name="intellij.platform.util" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -0,0 +1,25 @@
|
||||
<idea-plugin>
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<!--
|
||||
The advertisement's switch, separate from Figma Connect's own keys: those ship inside the
|
||||
plugin, and this runs where the plugin is absent. No restartRequired — the value is read on
|
||||
every getSuggestion call.
|
||||
|
||||
defaultValue is written twice, here and in FigmaAdvertiserRegistry, and they must agree.
|
||||
-->
|
||||
<registryKey defaultValue="true"
|
||||
description="Suggest the Figma Connect plugin over a Markdown file that links to Figma"
|
||||
key="markdown.figma.advertiser.enabled"/>
|
||||
|
||||
<!--
|
||||
Advertises Figma Connect over a Markdown file that links to a design. Deliberately not a
|
||||
dependency of that plugin, and deliberately not depended on by it: this runs when the plugin is
|
||||
absent, and the platform drops the suggestion once it is loaded.
|
||||
-->
|
||||
<pluginSuggestionProvider
|
||||
implementation="com.intellij.markdown.figmaAdvertiser.FigmaConnectPluginSuggestionProvider"/>
|
||||
|
||||
<statistics.counterUsagesCollector
|
||||
implementationClass="com.intellij.markdown.figmaAdvertiser.FigmaAdvertiserUsagesCollector"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
markdown.figma.suggestion.text=This file links to Figma. Figma Connect opens the design in the IDE and generates code from it.
|
||||
markdown.figma.suggestion.action.enable=Enable {0}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.intellij.markdown.figmaAdvertiser
|
||||
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
|
||||
/**
|
||||
* The advertisement's own switch, independent of Figma Connect's own keys.
|
||||
*
|
||||
* The plugin declares `figma.*` keys of its own, and none of them can serve here: they ship inside
|
||||
* the plugin, and this advertisement runs exactly where that plugin is absent. Someone who switches
|
||||
* the integration off and leaves this on still gets the offer.
|
||||
*
|
||||
* [ENABLED_BY_DEFAULT] is written twice, here and as `defaultValue` on the `<registryKey>`
|
||||
* declaration in `intellij.markdown.figmaAdvertiser.xml`. The declaration is what a running IDE
|
||||
* reads; this constant answers before the declaration is loaded. The two must agree.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
object FigmaAdvertiserRegistry {
|
||||
const val KEY_ADVERTISER_ENABLED: String = "markdown.figma.advertiser.enabled"
|
||||
|
||||
const val ENABLED_BY_DEFAULT: Boolean = true
|
||||
|
||||
val isAdvertiserEnabled: Boolean
|
||||
get() = Registry.`is`(KEY_ADVERTISER_ENABLED, ENABLED_BY_DEFAULT)
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package com.intellij.markdown.figmaAdvertiser
|
||||
|
||||
import com.intellij.internal.statistic.eventLog.EventLogGroup
|
||||
import com.intellij.internal.statistic.eventLog.events.EventFields
|
||||
import com.intellij.internal.statistic.eventLog.events.EventPair
|
||||
import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.FUSEventSource
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* What the Markdown advertisement for Figma Connect reports.
|
||||
*
|
||||
* Its own group rather than an extension of `figma.connect`: that group is declared inside the
|
||||
* plugin, which is absent exactly when this code runs, and FUS attributes a group to the plugin that
|
||||
* declares it. These events come from the Markdown plugin.
|
||||
*
|
||||
* The platform's `plugins.advertiser` group sees the same three moments as `suggestion.shown`,
|
||||
* `install.plugins` and `ignore.extensions`, and carries no field saying which trigger fired. Which
|
||||
* trigger converts is the question this advertisement exists to answer, so [TRIGGER] is the reason
|
||||
* this group exists. The install itself is the platform's to report and is not repeated here.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
object FigmaAdvertiserUsagesCollector : CounterUsagesCollector() {
|
||||
override fun getGroup(): EventLogGroup = GROUP
|
||||
|
||||
const val GROUP_ID: String = "markdown.figma.advertiser"
|
||||
|
||||
/** Bumped on any change to an event or a field of [GROUP_ID]. */
|
||||
const val GROUP_VERSION: Int = 1
|
||||
|
||||
private val GROUP = EventLogGroup(GROUP_ID, GROUP_VERSION)
|
||||
|
||||
/**
|
||||
* What made the offer appear.
|
||||
*
|
||||
* One value today, and a field rather than a property of the group, because the surfaces that
|
||||
* follow report their own triggers into groups of their own — they ship in other plugins, and FUS
|
||||
* attributes a group to the plugin that declares it. A query that reads the trigger reads the same
|
||||
* field name across all of them.
|
||||
*/
|
||||
enum class SuggestionTrigger {
|
||||
/** A Markdown file whose text links to a Figma design. */
|
||||
MARKDOWN_FIGMA_LINK,
|
||||
}
|
||||
|
||||
/**
|
||||
* Which surface carried the offer.
|
||||
*
|
||||
* The name is the [FUSEventSource] value the platform's `plugins.advertiser` group records for the
|
||||
* same clicks, so a query that joins the two groups reads one vocabulary.
|
||||
*/
|
||||
enum class SuggestionSurface {
|
||||
/** The banner over an open file. */
|
||||
EDITOR;
|
||||
|
||||
fun eventSource(): FUSEventSource = when (this) {
|
||||
EDITOR -> FUSEventSource.EDITOR
|
||||
}
|
||||
}
|
||||
|
||||
private val TRIGGER = EventFields.Enum<SuggestionTrigger>("trigger")
|
||||
private val SURFACE = EventFields.Enum<SuggestionSurface>("surface")
|
||||
|
||||
private val suggestionShown = GROUP.registerVarargEvent("suggestion.shown", TRIGGER, SURFACE)
|
||||
private val suggestionAccepted = GROUP.registerVarargEvent("suggestion.accepted", TRIGGER, SURFACE)
|
||||
private val suggestionDismissed = GROUP.registerVarargEvent("suggestion.dismissed", TRIGGER, SURFACE)
|
||||
|
||||
/**
|
||||
* The offer is on screen.
|
||||
*
|
||||
* `PluginSuggestion.apply` runs per file editor and again on every
|
||||
* `EditorNotifications.updateAllNotifications()`, so an unguarded record would count repaints.
|
||||
* [ShownSuggestions] claims a shape once for the project, and the count is of projects that saw
|
||||
* the offer.
|
||||
*/
|
||||
fun logSuggestionShown(project: Project, trigger: SuggestionTrigger, surface: SuggestionSurface) {
|
||||
if (!project.service<ShownSuggestions>().claim(trigger, surface)) return
|
||||
suggestionShown.log(project, fieldsOf(trigger, surface))
|
||||
}
|
||||
|
||||
/**
|
||||
* The user asked for the plugin.
|
||||
*
|
||||
* The platform records the same click as `plugins.advertiser` `install.plugins`, and that record
|
||||
* says nothing about which trigger the user was answering.
|
||||
*/
|
||||
fun logSuggestionAccepted(project: Project, trigger: SuggestionTrigger, surface: SuggestionSurface) {
|
||||
suggestionAccepted.log(project, fieldsOf(trigger, surface))
|
||||
}
|
||||
|
||||
/**
|
||||
* The user closed the offer for good.
|
||||
*
|
||||
* The platform's `ignore.extensions` carries the surface and no plugin id, so a dismissal is
|
||||
* countable there only across every plugin at once. This event attributes one to this
|
||||
* advertisement.
|
||||
*/
|
||||
fun logSuggestionDismissed(project: Project, trigger: SuggestionTrigger, surface: SuggestionSurface) {
|
||||
suggestionDismissed.log(project, fieldsOf(trigger, surface))
|
||||
}
|
||||
|
||||
private fun fieldsOf(trigger: SuggestionTrigger, surface: SuggestionSurface): List<EventPair<*>> =
|
||||
listOf(TRIGGER.with(trigger), SURFACE.with(surface))
|
||||
}
|
||||
|
||||
/**
|
||||
* Which shapes a project has already reported as shown.
|
||||
*
|
||||
* Per project, and not the application-wide set the platform keeps for its own `suggestion.shown`
|
||||
* (`PluginAdvertiserEditorNotificationProvider.kt:311`). That set answers "how many IDE runs saw
|
||||
* this banner". This group is registered to answer a per-project question, and an application-wide
|
||||
* set would report one showing while `suggestion.accepted` counted every project that acted.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
@Service(Service.Level.PROJECT)
|
||||
class ShownSuggestions {
|
||||
private val shown = ConcurrentHashMap.newKeySet<ShownSuggestion>()
|
||||
|
||||
/** True the first time this project sees this shape, false every time after. */
|
||||
fun claim(
|
||||
trigger: FigmaAdvertiserUsagesCollector.SuggestionTrigger,
|
||||
surface: FigmaAdvertiserUsagesCollector.SuggestionSurface,
|
||||
): Boolean = shown.add(ShownSuggestion(trigger, surface))
|
||||
|
||||
private data class ShownSuggestion(
|
||||
val trigger: FigmaAdvertiserUsagesCollector.SuggestionTrigger,
|
||||
val surface: FigmaAdvertiserUsagesCollector.SuggestionSurface,
|
||||
)
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.intellij.markdown.figmaAdvertiser
|
||||
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaAdvertiserUsagesCollector.SuggestionSurface
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaAdvertiserUsagesCollector.SuggestionTrigger
|
||||
import com.intellij.openapi.fileEditor.FileEditor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.PluginSuggestion
|
||||
import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.PluginSuggestionProvider
|
||||
import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.buildSuggestionIfNeeded
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.ui.EditorNotificationPanel
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
|
||||
/**
|
||||
* Offers Figma Connect over a Markdown file whose text links to a Figma design.
|
||||
*
|
||||
* Lives in the Markdown plugin rather than in Figma Connect: a suggestion shipped by the plugin it
|
||||
* suggests would only ever run too late.
|
||||
*
|
||||
* Everything decidable is decided in `FigmaSuggestionDecision.kt` and `FigmaLinkScan.kt`, which are
|
||||
* a pure function per question and are where the tests are. The offer this class prints and the
|
||||
* actions it carries are [FigmaSuggestionOffer]. This class turns those answers into Swing.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
class FigmaConnectPluginSuggestionProvider : PluginSuggestionProvider {
|
||||
|
||||
override fun getSuggestion(project: Project, file: VirtualFile): PluginSuggestion? {
|
||||
// The switch first: this is asked about every file that is opened, and a registry read is
|
||||
// cheaper than the path match below, which is string work.
|
||||
if (!FigmaAdvertiserRegistry.isAdvertiserEnabled) return null
|
||||
if (!isMarkdownSuggestionFile(file.path)) return null
|
||||
if (isFigmaSuggestionDismissed(project)) return null
|
||||
|
||||
// Asked before the file is read, and it can be: the offer says the same thing over every file,
|
||||
// so nothing here needs the text. This drops every plugin id that is already loaded and returns
|
||||
// null once the list is empty, which is how a user who already runs Figma Connect is excluded.
|
||||
val needed = buildSuggestionIfNeeded(
|
||||
project,
|
||||
pluginIds = listOf(FIGMA_CONNECT_PLUGIN_ID),
|
||||
pluginName = FIGMA_CONNECT_PLUGIN_NAME,
|
||||
suggestionText = figmaSuggestionText(),
|
||||
suggestionDismissKey = PLATFORM_GUARD_DISMISS_KEY,
|
||||
) ?: return null
|
||||
|
||||
// The file's own text is read last, so nothing above is paid for by a read.
|
||||
if (!linksToFigma(file)) return null
|
||||
|
||||
return FigmaConnectPluginSuggestion(
|
||||
pluginIds = needed.pluginIds,
|
||||
offer = FigmaSuggestionOffer(project, SuggestionTrigger.MARKDOWN_FIGMA_LINK, SuggestionSurface.EDITOR),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers to enable an installed-but-disabled plugin, or to install a missing one, and then opens the
|
||||
* tool window so that the click leads somewhere.
|
||||
*
|
||||
* The platform's own `DefaultPluginSuggestion` is `internal`, and only installs; it also leaves the
|
||||
* user in front of a tool window they have to find.
|
||||
*/
|
||||
private class FigmaConnectPluginSuggestion(
|
||||
override val pluginIds: List<String>,
|
||||
private val offer: FigmaSuggestionOffer,
|
||||
) : PluginSuggestion {
|
||||
|
||||
override fun apply(fileEditor: FileEditor): EditorNotificationPanel {
|
||||
val panel = EditorNotificationPanel(fileEditor, EditorNotificationPanel.Status.Info)
|
||||
panel.text = offer.text
|
||||
|
||||
// Where the platform logs its own `suggestion.shown` for this panel, so the two counts line up.
|
||||
offer.reportShown()
|
||||
|
||||
panel.createActionLabel(offer.primaryActionText) { offer.accept() }
|
||||
panel.createActionLabel(offer.dismissActionText) { offer.dismiss() }
|
||||
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key [buildSuggestionIfNeeded] is given for its `suggestionDismissKey` parameter.
|
||||
*
|
||||
* This module owns the key and sets no value under it. The application-level guard that call opens
|
||||
* with passes, and the call answers the loaded-plugin question. [isFigmaSuggestionDismissed] is the
|
||||
* dismissal check, and it reads an answer recorded on one project, which an application-level guard
|
||||
* cannot express.
|
||||
*/
|
||||
private const val PLATFORM_GUARD_DISMISS_KEY: String = "markdown.figma.connect.advertiser.platform.guard"
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.intellij.markdown.figmaAdvertiser
|
||||
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.openapi.vfs.VirtualFileCachedValue
|
||||
import com.intellij.openapi.vfs.getCachedValue
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
|
||||
/**
|
||||
* Holds the answer [linksToFigma] gave for one file. It is absent for a file that was never read,
|
||||
* which is a different state from a file that was read and answered false.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
val FIGMA_LINK_SCAN_KEY: Key<VirtualFileCachedValue<Boolean>> =
|
||||
Key.create("markdown.figma.link.scan")
|
||||
|
||||
/**
|
||||
* Whether [file] contains a link to a Figma file.
|
||||
*
|
||||
* `EditorNotificationsImpl` asks the suggestion providers on a background thread, inside a read
|
||||
* action. This reads the file's own text and asks no index and no PSI, so it answers the same way
|
||||
* while the index is being built.
|
||||
*
|
||||
* `VirtualFile.getCachedValue` keeps the answer on the file and drops it when the file's
|
||||
* modification stamp or its loaded document's stamp moves, and it reads the loaded document where
|
||||
* there is one. The banner is asked about a file an editor is showing, so the usual read costs no
|
||||
* I/O. Two calls that race both scan and both store the same answer.
|
||||
*
|
||||
* The whole text is scanned. A link to a design sits wherever the author put it, so a prefix scan
|
||||
* would miss the ones at the bottom of a long document.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
fun linksToFigma(file: VirtualFile): Boolean =
|
||||
// The length comes from the VFS record, so a file over the cap is never loaded.
|
||||
file.length <= MAX_SCANNED_FILE_BYTES &&
|
||||
file.getCachedValue(FIGMA_LINK_SCAN_KEY) { _, text -> text != null && containsFigmaUrl(text) }
|
||||
|
||||
/**
|
||||
* How large a file the banner reads at all, in bytes.
|
||||
*
|
||||
* A Markdown document a person wrote is far below this. A file above it is generated, and skipping
|
||||
* it by its recorded length keeps it out of both the read and the scan.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
const val MAX_SCANNED_FILE_BYTES: Long = 1L shl 20
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.intellij.markdown.figmaAdvertiser
|
||||
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
|
||||
/**
|
||||
* Whether the banner is willing to look inside [filePath].
|
||||
*
|
||||
* The provider is asked about every file that is opened, and this is the second question it answers,
|
||||
* so it is a string check over the VFS path. VFS paths are `/`-separated on every OS.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
fun isMarkdownSuggestionFile(filePath: String): Boolean {
|
||||
val name = filePath.substringAfterLast('/')
|
||||
return name.substringAfterLast('.', "").lowercase() in MARKDOWN_EXTENSIONS
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [text] contains a link to a Figma file.
|
||||
*
|
||||
* `FigmaUrlDetector` in the Figma Connect plugin holds the other copy of this rule, and the two
|
||||
* copies must both stay. This advertisement runs exactly when that plugin is absent, so a module
|
||||
* dependency on it would be dropped in the one case the advertisement exists for, and the banner
|
||||
* would never appear. Neither copy is the original; whoever changes the set of Figma URL shapes
|
||||
* changes both.
|
||||
*
|
||||
* **The two rules answer different questions, and this one is deliberately the broader.**
|
||||
* `FigmaUrlDetector` asks which Figma node to open, so it requires a file key after the path
|
||||
* segment and parses the node id out. This asks whether the author works with Figma, and a
|
||||
* truncated or keyless link — `https://www.figma.com/design/`, `.../file/-abc/Checkout` — still
|
||||
* answers yes. So the banner appears over a URL Figma Connect itself cannot open. That is the right
|
||||
* trade for a suggestion: the cost is one offer the user can dismiss, and narrowing the rule to
|
||||
* match the detector would drop real matches for a question this function is not asking.
|
||||
*
|
||||
* Do not align the two. Whoever adds a Figma URL shape adds it to both; whoever tightens either one
|
||||
* has to say which of the two questions changed.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
fun containsFigmaUrl(text: CharSequence): Boolean = FIGMA_URL_PATTERN.containsMatchIn(text)
|
||||
|
||||
/**
|
||||
* Lower case. A file system keeps the case a user typed, and `README.MD` names the same extension.
|
||||
*
|
||||
* Mirrors the `extensions` attribute of the `Markdown` file type
|
||||
* (`community/plugins/markdown/core/resources/META-INF/plugin.xml:194`). A user who maps another
|
||||
* extension to Markdown is not followed here, and pays one surface for it.
|
||||
*/
|
||||
private val MARKDOWN_EXTENSIONS: Set<String> = setOf("md", "markdown", "mdc")
|
||||
|
||||
/**
|
||||
* `figma.com/file/`, `figma.com/design/` and `figma.com/proto/`, with or without a `www.` host
|
||||
* prefix and over either scheme. A bare `figma.com` mention is not a link to a design and does not
|
||||
* match.
|
||||
*/
|
||||
private val FIGMA_URL_PATTERN: Regex =
|
||||
Regex("""https?://(?:www\.)?figma\.com/(?:file|design|proto)/""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** The plugin this module advertises. Owned by `plugins/figma/resources/META-INF/plugin.xml:2`. */
|
||||
@ApiStatus.Internal
|
||||
const val FIGMA_CONNECT_PLUGIN_ID: String = "com.intellij.figma"
|
||||
|
||||
/** Its `<name>`, which the platform's Install action label is built from. */
|
||||
@ApiStatus.Internal
|
||||
const val FIGMA_CONNECT_PLUGIN_NAME: String = "Figma Connect"
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.intellij.markdown.figmaAdvertiser
|
||||
|
||||
import com.intellij.ide.util.PropertiesComponent
|
||||
import com.intellij.openapi.project.Project
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
|
||||
/**
|
||||
* The key the dismissal is recorded under, on the project's own `PropertiesComponent`.
|
||||
*
|
||||
* The platform stores nothing per project: `buildSuggestionIfNeeded` reads its `suggestionDismissKey`
|
||||
* from the application, so an answer given there would silence the offer everywhere. A user who meets
|
||||
* the banner in a project where the design link is somebody else's still gets the offer in the
|
||||
* project where the link is theirs.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
const val FIGMA_SUGGESTION_DISMISSED_KEY: String = "markdown.figma.connect.suggestion.dismissed"
|
||||
|
||||
/** Whether the banner is dismissed for [project]. */
|
||||
@ApiStatus.Internal
|
||||
fun isFigmaSuggestionDismissed(project: Project): Boolean =
|
||||
PropertiesComponent.getInstance(project).isTrueValue(FIGMA_SUGGESTION_DISMISSED_KEY)
|
||||
|
||||
/** Records that the banner is dismissed for [project]. */
|
||||
@ApiStatus.Internal
|
||||
fun dismissFigmaSuggestion(project: Project) {
|
||||
PropertiesComponent.getInstance(project).setValue(FIGMA_SUGGESTION_DISMISSED_KEY, true)
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package com.intellij.markdown.figmaAdvertiser
|
||||
|
||||
import com.intellij.ide.IdeBundle
|
||||
import com.intellij.ide.plugins.IdeaPluginDescriptor
|
||||
import com.intellij.ide.plugins.PluginEnabler
|
||||
import com.intellij.ide.plugins.PluginManagerCore
|
||||
import com.intellij.ide.plugins.PluginManagerMain
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaAdvertiserUsagesCollector.SuggestionSurface
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaAdvertiserUsagesCollector.SuggestionTrigger
|
||||
import com.intellij.openapi.extensions.PluginId
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.installAndEnable
|
||||
import com.intellij.openapi.wm.ToolWindowManager
|
||||
import com.intellij.ui.EditorNotifications
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import org.jetbrains.annotations.Nls
|
||||
|
||||
/**
|
||||
* What the offer says, and what each action does when the user takes it.
|
||||
*
|
||||
* The provider turns these answers into Swing. Everything that decides something lives outside the
|
||||
* panel so that it can be asked without one.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
class FigmaSuggestionOffer(
|
||||
private val project: Project,
|
||||
private val trigger: SuggestionTrigger,
|
||||
private val surface: SuggestionSurface,
|
||||
) {
|
||||
private val pluginId = PluginId.getId(FIGMA_CONNECT_PLUGIN_ID)
|
||||
|
||||
/**
|
||||
* The plugin's descriptor when it is on disk, switched on or off.
|
||||
*
|
||||
* On disk is what the offer's own word has to follow, and `isDisabled` answers a different
|
||||
* question. The banner is drawn whenever the plugin is not **loaded**
|
||||
* (`buildSuggestionIfNeeded` filters on `PluginManager.getLoadedPlugins()`), so a plugin on disk
|
||||
* and switched on still reaches it, and offering that user an install would send them to
|
||||
* Marketplace for a plugin they already have.
|
||||
*/
|
||||
private val installedPlugin: IdeaPluginDescriptor? = PluginManagerCore.getPlugin(pluginId)
|
||||
|
||||
val text: @Nls String = figmaSuggestionText()
|
||||
|
||||
val primaryActionText: @Nls String =
|
||||
if (installedPlugin == null) {
|
||||
IdeBundle.message("plugins.advertiser.action.install.plugin.name", FIGMA_CONNECT_PLUGIN_NAME)
|
||||
}
|
||||
else {
|
||||
MarkdownFigmaAdvertiserBundle.message("markdown.figma.suggestion.action.enable", FIGMA_CONNECT_PLUGIN_NAME)
|
||||
}
|
||||
|
||||
val dismissActionText: @Nls String = IdeBundle.message("plugins.advertiser.action.ignore.ultimate")
|
||||
|
||||
/** The offer is on screen. */
|
||||
fun reportShown() {
|
||||
FigmaAdvertiserUsagesCollector.logSuggestionShown(project, trigger, surface)
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the plugin, or turns an installed one back on, and opens the setup wizard.
|
||||
*
|
||||
* Runs on the EDT: a `createActionLabel` click is what calls this.
|
||||
*
|
||||
* **The restart offer is the platform's own, and nothing is added here.** `installAndEnable` runs
|
||||
* the success body through `InstallAndEnableTask.runOnSuccess`, which is given the result of
|
||||
* `PluginInstaller.installAndLoadDynamicPlugin` — false when the plugin needs a restart. On that
|
||||
* branch `PluginsAdvertiserDialogPluginInstaller` has already run
|
||||
* `PluginManagerMain.notifyPluginsUpdated`, whose notification carries the platform's own
|
||||
* "Restart to activate plugin updates" action, so the restart is asked for exactly once.
|
||||
*
|
||||
* `PluginManagerMain` is `@ApiStatus.Internal` and a bundled plugin already calls it:
|
||||
* `plugins/hunspell/src/com/intellij/hunspell/HunspellStartupActivity.kt:55`.
|
||||
*
|
||||
* `DynamicPluginEnabler.enable`, which is what `PluginEnabler.getInstance()` answers in a running
|
||||
* IDE, reports whether the descriptors were **loaded**, and it reports it correctly only when it is
|
||||
* called on the EDT: its `runInEdt { … }` runs the block inline there, and the value it reads back
|
||||
* is the block's. `getInstance()` answers `DisabledPluginsState` before
|
||||
* `LoadingState.COMPONENTS_LOADED` and on a disposed application, and that one reports whether the
|
||||
* disabled set changed instead; a banner click reaches neither state. This path runs no installer,
|
||||
* so it raises the platform's notification itself.
|
||||
*/
|
||||
fun accept() {
|
||||
val source = surface.eventSource()
|
||||
FigmaAdvertiserUsagesCollector.logSuggestionAccepted(project, trigger, surface)
|
||||
val installed = installedPlugin
|
||||
if (installed == null) {
|
||||
source.logInstallPlugins(listOf(FIGMA_CONNECT_PLUGIN_ID), project)
|
||||
installAndEnable(project, setOf(pluginId), showDialog = true) { openSetupWizard() }
|
||||
return
|
||||
}
|
||||
// Nothing is downloaded on this branch, so the platform's record of the click is an enable.
|
||||
source.logEnablePlugins(listOf(FIGMA_CONNECT_PLUGIN_ID), project)
|
||||
// One call for both on-disk states. `enable` flips the disabled flag when there is one to flip
|
||||
// and then loads the descriptor, so a plugin that is on disk and switched on is loaded in this
|
||||
// session rather than sent to a restart it does not need.
|
||||
if (PluginEnabler.getInstance().enable(listOf(installed))) {
|
||||
openSetupWizard()
|
||||
}
|
||||
else {
|
||||
PluginManagerMain.notifyPluginsUpdated(project)
|
||||
}
|
||||
}
|
||||
|
||||
/** Records the answer on the project, and takes the banner down. */
|
||||
fun dismiss() {
|
||||
surface.eventSource().logIgnoreExtension(project)
|
||||
FigmaAdvertiserUsagesCollector.logSuggestionDismissed(project, trigger, surface)
|
||||
dismissFigmaSuggestion(project)
|
||||
EditorNotifications.getInstance(project).updateAllNotifications()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Figma tool window, which is where the setup wizard is shown.
|
||||
*
|
||||
* `FigmaWelcomeSurface` picks the wizard over the recap panel when `FigmaToolWindowContent` is
|
||||
* built, so the window is the wizard's entry point for a user who has not completed it on this
|
||||
* machine. A user who has completed it before, or one whose IDE has `figma.setup.wizard.enabled`
|
||||
* off, gets the recap panel instead, which is the first-run surface for them.
|
||||
*
|
||||
* **The `?.` is load-bearing.** `PluginManagerMain.downloadPluginsImpl` schedules the runnable
|
||||
* that ANDs in the dynamic-load result and the one that calls back here under different modality
|
||||
* states, so which runs first is not guaranteed. A plugin that did not load registers no tool
|
||||
* window, `getToolWindow` answers null, and this call does nothing.
|
||||
*/
|
||||
private fun openSetupWizard() {
|
||||
ToolWindowManager.getInstance(project).getToolWindow(FIGMA_TOOL_WINDOW_ID)?.activate(null)
|
||||
EditorNotifications.getInstance(project).updateAllNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
@ApiStatus.Internal
|
||||
fun figmaSuggestionText(): @Nls String = MarkdownFigmaAdvertiserBundle.message("markdown.figma.suggestion.text")
|
||||
|
||||
/**
|
||||
* Owned by Figma Connect (`plugins/figma/frontend/resources/intellij.figma.frontend.xml:65`) and
|
||||
* repeated here, because this module must not depend on the plugin it advertises: the offer exists
|
||||
* for the case where that plugin is absent, and a content module whose dependency is missing is
|
||||
* dropped silently. It is what `ActivateToolWindowAction` is keyed on, so it does not move quietly.
|
||||
*/
|
||||
private const val FIGMA_TOOL_WINDOW_ID: String = "Figma"
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.intellij.markdown.figmaAdvertiser
|
||||
|
||||
import com.intellij.DynamicBundle
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import org.jetbrains.annotations.Nls
|
||||
import org.jetbrains.annotations.NonNls
|
||||
import org.jetbrains.annotations.PropertyKey
|
||||
|
||||
@NonNls
|
||||
private const val BUNDLE = "messages.MarkdownFigmaAdvertiserBundle"
|
||||
|
||||
/**
|
||||
* The advertisement's own bundle, separate from `MarkdownBundle`.
|
||||
*
|
||||
* The Markdown plugin's own strings and this advertisement's are edited by different people: the
|
||||
* offer text is Figma Connect's marketing copy, and a bundle of its own keeps a wording change out
|
||||
* of the file every Markdown string lives in.
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
object MarkdownFigmaAdvertiserBundle {
|
||||
private val bundle = DynamicBundle(this::class.java, BUNDLE)
|
||||
|
||||
@Nls
|
||||
fun message(
|
||||
@PropertyKey(resourceBundle = BUNDLE) key: String,
|
||||
vararg params: Any,
|
||||
): String = bundle.getMessage(key, *params)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ jvm_library(
|
||||
"//platform/structure-view-impl/plugin",
|
||||
"//libraries/kotlinx/collections-immutable:libraries-kotlinx-collections-immutable",
|
||||
"//platform/lang-core",
|
||||
"//plugins/markdown/figmaAdvertiser",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -210,6 +211,15 @@ jvm_library(
|
||||
"//libraries/junit4:junit4_test_lib",
|
||||
"//libraries/junit5",
|
||||
"//libraries/junit5:junit5_test_lib",
|
||||
"//plugins/markdown/figmaAdvertiser",
|
||||
"//plugins/markdown/figmaAdvertiser:figmaAdvertiser_test_lib",
|
||||
"@lib//:kotlin-test-assertions-core-jvm",
|
||||
"//platform/statistics",
|
||||
"//platform/statistics:statistics_test_lib",
|
||||
"//platform/statistics/testFramework",
|
||||
"//platform/statistics/testFramework:testFramework_test_lib",
|
||||
"//platform/pluginSystem/testFramework",
|
||||
"//platform/pluginSystem/testFramework:testFramework_test_lib",
|
||||
],
|
||||
)
|
||||
### auto-generated section `build intellij.markdown.tests` end
|
||||
|
||||
@@ -77,5 +77,10 @@
|
||||
<orderEntry type="module" module-name="intellij.textmate.plugin" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.libraries.junit4" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.libraries.junit5" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.markdown.figmaAdvertiser" />
|
||||
<orderEntry type="library" scope="TEST" name="kotlin-test-assertions-core-jvm" level="project" />
|
||||
<orderEntry type="module" module-name="intellij.platform.statistics" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.platform.statistics.testFramework" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.platform.pluginSystem.testFramework" scope="TEST" />
|
||||
</component>
|
||||
</module>
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
package org.intellij.plugins.markdown.figmaAdvertiser
|
||||
|
||||
import com.intellij.ide.IdeBundle
|
||||
import com.intellij.ide.plugins.IdeaPluginDescriptor
|
||||
import com.intellij.ide.plugins.PluginEnabler
|
||||
import com.intellij.ide.plugins.PluginManager
|
||||
import com.intellij.ide.plugins.PluginManagerCore
|
||||
import com.intellij.ide.util.PropertiesComponent
|
||||
import com.intellij.internal.statistic.FUCollectorTestCase
|
||||
import com.intellij.markdown.figmaAdvertiser.FIGMA_CONNECT_PLUGIN_ID
|
||||
import com.intellij.markdown.figmaAdvertiser.FIGMA_CONNECT_PLUGIN_NAME
|
||||
import com.intellij.markdown.figmaAdvertiser.FIGMA_LINK_SCAN_KEY
|
||||
import com.intellij.markdown.figmaAdvertiser.FIGMA_SUGGESTION_DISMISSED_KEY
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaAdvertiserRegistry
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaAdvertiserUsagesCollector
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaConnectPluginSuggestionProvider
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaSuggestionOffer
|
||||
import com.intellij.markdown.figmaAdvertiser.MarkdownFigmaAdvertiserBundle
|
||||
import com.intellij.markdown.figmaAdvertiser.ShownSuggestions
|
||||
import com.intellij.markdown.figmaAdvertiser.isFigmaSuggestionDismissed
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.extensions.ExtensionPointName
|
||||
import com.intellij.openapi.extensions.PluginId
|
||||
import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.io.FileUtil
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.PluginSuggestion
|
||||
import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.PluginSuggestionProvider
|
||||
import com.intellij.platform.pluginSystem.testFramework.PluginSetTestBuilder
|
||||
import com.intellij.platform.testFramework.loadPluginWithText
|
||||
import com.intellij.platform.testFramework.plugins.dependsIntellijModulesLang
|
||||
import com.intellij.platform.testFramework.plugins.installAt
|
||||
import com.intellij.platform.testFramework.plugins.plugin
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.testFramework.replaceService
|
||||
import com.intellij.ui.EditorNotificationPanel
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.kotest.matchers.types.shouldBeSameInstanceAs
|
||||
|
||||
/**
|
||||
* The banner over a Markdown file, and the wiring that puts it there.
|
||||
*
|
||||
* `FigmaAdvertiserUsagesCollector` reports one shape, so a `suggestion.shown` claimed by one case
|
||||
* would silence every case after it. [setUp] gives each case its own [ShownSuggestions], and
|
||||
* [tearDown] clears the dismissal the project carries, for the same reason: a
|
||||
* `BasePlatformTestCase` shares one project across the class.
|
||||
*/
|
||||
class MarkdownFigmaAdvertiserBannerTest : BasePlatformTestCase() {
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
// Asked for rather than inherited: a branch that ships the advertisement off would otherwise
|
||||
// turn these into failures that name the assertion and not the cause.
|
||||
Registry.get(FigmaAdvertiserRegistry.KEY_ADVERTISER_ENABLED).setValue(true, testRootDisposable)
|
||||
project.replaceService(ShownSuggestions::class.java, ShownSuggestions(), testRootDisposable)
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
PropertiesComponent.getInstance(project).unsetValue(FIGMA_SUGGESTION_DISMISSED_KEY)
|
||||
PropertiesComponent.getInstance().unsetValue(FIGMA_SUGGESTION_DISMISSED_KEY)
|
||||
}
|
||||
catch (e: Throwable) {
|
||||
addSuppressedException(e)
|
||||
}
|
||||
finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test a Markdown file that links to Figma is offered the banner`() {
|
||||
val panel = bannerOver(designNote())
|
||||
|
||||
panel.text shouldBe MarkdownFigmaAdvertiserBundle.message("markdown.figma.suggestion.text")
|
||||
panel.findLabelByName(INSTALL_LABEL).shouldNotBeNull()
|
||||
panel.findLabelByName(DISMISS_LABEL).shouldNotBeNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The mechanism above says nothing about whether anything asks it. This goes through the
|
||||
* extension point the platform's banner reads
|
||||
* (`PluginAdvertiserEditorNotificationProvider.kt:71`), so the descriptor's registration is what
|
||||
* is pinned. The plugin id is the value only this advertisement produces.
|
||||
*/
|
||||
fun `test the extension point reaches the provider over a Markdown file`() {
|
||||
val note = designNote()
|
||||
|
||||
val suggestion = PLUGIN_SUGGESTION_EP.extensionList
|
||||
.firstNotNullOfOrNull { it.getSuggestion(project, note.virtualFile) }
|
||||
|
||||
suggestion.shouldNotBeNull()
|
||||
suggestion.pluginIds shouldBe listOf(FIGMA_CONNECT_PLUGIN_ID)
|
||||
panelOf(suggestion).text shouldBe MarkdownFigmaAdvertiserBundle.message("markdown.figma.suggestion.text")
|
||||
}
|
||||
|
||||
/** The record says which trigger the user answered, which is what this group exists for. */
|
||||
fun `test the shown event names the Markdown trigger and the editor surface`() {
|
||||
val note = designNote()
|
||||
|
||||
val events = FUCollectorTestCase.collectLogEvents(testRootDisposable) {
|
||||
panelOf(suggestionOver(note).shouldNotBeNull())
|
||||
}
|
||||
|
||||
val shown = events.single {
|
||||
it.group.id == FigmaAdvertiserUsagesCollector.GROUP_ID && it.event.id == "suggestion.shown"
|
||||
}
|
||||
shown.event.data["trigger"] shouldBe "MARKDOWN_FIGMA_LINK"
|
||||
shown.event.data["surface"] shouldBe "EDITOR"
|
||||
}
|
||||
|
||||
/**
|
||||
* `PluginSuggestion.apply` runs per file editor and again on every
|
||||
* `EditorNotifications.updateAllNotifications()`, so an unguarded record would count repaints.
|
||||
*/
|
||||
fun `test the shown event fires once for a project however often the banner is painted`() {
|
||||
val note = designNote()
|
||||
|
||||
val events = FUCollectorTestCase.collectLogEvents(testRootDisposable) {
|
||||
repeat(3) { panelOf(suggestionOver(note).shouldNotBeNull()) }
|
||||
}
|
||||
|
||||
events.filter {
|
||||
it.group.id == FigmaAdvertiserUsagesCollector.GROUP_ID && it.event.id == "suggestion.shown"
|
||||
} shouldHaveSize 1
|
||||
}
|
||||
|
||||
/** A Markdown file that links to nothing earns no banner, and the answer is kept on the file. */
|
||||
fun `test a Markdown file without a Figma link earns no banner and is read once`() {
|
||||
val note = myFixture.configureByText("notes.md", "We talked about the design in the meeting.\n")
|
||||
|
||||
suggestionOver(note).shouldBeNull()
|
||||
val firstScan = FIGMA_LINK_SCAN_KEY.get(note.virtualFile).shouldNotBeNull()
|
||||
|
||||
suggestionOver(note).shouldBeNull()
|
||||
|
||||
// The same value object, so the second ask read the answer off the file and not the text.
|
||||
FIGMA_LINK_SCAN_KEY.get(note.virtualFile) shouldBeSameInstanceAs firstScan
|
||||
}
|
||||
|
||||
/**
|
||||
* The path decides before the file is opened. A user opens files all day, and none of them is
|
||||
* read for this banner unless Markdown claims the extension.
|
||||
*/
|
||||
fun `test a file that is not Markdown is never read`() {
|
||||
val note = myFixture.configureByText("notes.txt", "The spec is at https://www.figma.com/design/AbC123/Checkout\n")
|
||||
|
||||
suggestionOver(note).shouldBeNull()
|
||||
|
||||
FIGMA_LINK_SCAN_KEY.get(note.virtualFile).shouldBeNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells a switched-off advertisement from a file it has nothing to say about. The same file earns
|
||||
* the banner in the cases above, so what changed is the key.
|
||||
*/
|
||||
fun `test no banner is offered while the advertiser is switched off`() {
|
||||
val note = designNote()
|
||||
Registry.get(FigmaAdvertiserRegistry.KEY_ADVERTISER_ENABLED).setValue(false, testRootDisposable)
|
||||
|
||||
suggestionOver(note).shouldBeNull()
|
||||
|
||||
// The switch is read before the path is, so the file is not read either.
|
||||
FIGMA_LINK_SCAN_KEY.get(note.virtualFile).shouldBeNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer is recorded on the project that was asked. A second project keeps the offer, which is
|
||||
* the accepted cost of not silencing the suggestion for someone who met it in the wrong project.
|
||||
*/
|
||||
fun `test Dismiss records the answer on the project that was asked`() {
|
||||
val note = designNote()
|
||||
val panel = bannerOver(note)
|
||||
|
||||
panel.findLabelByName(DISMISS_LABEL)!!.doClick()
|
||||
|
||||
PropertiesComponent.getInstance(project).isTrueValue(FIGMA_SUGGESTION_DISMISSED_KEY).shouldBeTrue()
|
||||
PropertiesComponent.getInstance().isTrueValue(FIGMA_SUGGESTION_DISMISSED_KEY).shouldBeFalse()
|
||||
// The default project is a second project this test can read without opening one.
|
||||
isFigmaSuggestionDismissed(ProjectManager.getInstance().defaultProject).shouldBeFalse()
|
||||
suggestionOver(note).shouldBeNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* With Figma Connect loaded, nothing is drawn, and the exclusion is
|
||||
* `buildSuggestionIfNeeded`'s already-loaded filter rather than a check of this module's own.
|
||||
*
|
||||
* `loadPluginWithText` returns a `Disposable` that unloads and uninstalls, so the plugin does not
|
||||
* outlive the case. The three asks around it are what make the middle one mean something: the same
|
||||
* provider and the same file answer non-null before and after.
|
||||
*/
|
||||
fun `test nothing is offered while a plugin with the Figma Connect id is loaded`() {
|
||||
val note = designNote()
|
||||
val provider = FigmaConnectPluginSuggestionProvider()
|
||||
provider.getSuggestion(project, note.virtualFile).shouldNotBeNull()
|
||||
|
||||
val unload = loadFigmaConnectId()
|
||||
try {
|
||||
PluginManager.getLoadedPlugins().any { it.pluginId.idString == FIGMA_CONNECT_PLUGIN_ID }.shouldBeTrue()
|
||||
provider.getSuggestion(project, note.virtualFile).shouldBeNull()
|
||||
}
|
||||
finally {
|
||||
Disposer.dispose(unload)
|
||||
}
|
||||
|
||||
provider.getSuggestion(project, note.virtualFile).shouldNotBeNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The offer's word follows whether the plugin is on disk, not whether it is switched off. A
|
||||
* plugin that is on disk needs no install, and offering one sends the user to Marketplace for
|
||||
* something they already have.
|
||||
*
|
||||
* The fixture is a **loaded** plugin, the cheap way to make `getPlugin` answer. The state the
|
||||
* predicate actually matters in — on disk, switched on, not loaded — is the case below. The offer
|
||||
* is built directly because `buildSuggestionIfNeeded` drops a loaded plugin, so `getSuggestion`
|
||||
* cannot reach this.
|
||||
*/
|
||||
fun `test the offer asks to enable a plugin that is already on disk`() {
|
||||
val unload = loadFigmaConnectId()
|
||||
try {
|
||||
val offer = offerForTest()
|
||||
|
||||
offer.primaryActionText shouldBe
|
||||
MarkdownFigmaAdvertiserBundle.message("markdown.figma.suggestion.action.enable", FIGMA_CONNECT_PLUGIN_NAME)
|
||||
offer.primaryActionText shouldNotBe INSTALL_LABEL
|
||||
}
|
||||
finally {
|
||||
Disposer.dispose(unload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On disk, switched on, and not loaded — the state `PluginEnabler.HEADLESS.enable` leaves behind,
|
||||
* because it writes the disabled flag and loads nothing. The offer must not call it an install,
|
||||
* and `isDisabled` answers false here, so the label cannot be derived from that.
|
||||
*
|
||||
* The state is built by publishing a plugin set the id is installed in and not enabled in, which
|
||||
* leaves `DisabledPluginsState` untouched. `PluginSet`'s constructor is `internal`, so
|
||||
* `PluginSetTestBuilder` is the way in, and the original set is put back in a `finally` because it
|
||||
* is application-wide.
|
||||
*/
|
||||
fun `test the offer asks to enable a plugin on disk that is switched on and not loaded`() {
|
||||
withFigmaConnectOnDiskAndSwitchedOn {
|
||||
PluginManagerCore.getPlugin(PluginId.getId(FIGMA_CONNECT_PLUGIN_ID)).shouldNotBeNull()
|
||||
PluginManagerCore.isDisabled(PluginId.getId(FIGMA_CONNECT_PLUGIN_ID)).shouldBeFalse()
|
||||
PluginManager.getLoadedPlugins().none { it.pluginId.idString == FIGMA_CONNECT_PLUGIN_ID }.shouldBeTrue()
|
||||
|
||||
val offer = offerForTest()
|
||||
offer.primaryActionText shouldBe
|
||||
MarkdownFigmaAdvertiserBundle.message("markdown.figma.suggestion.action.enable", FIGMA_CONNECT_PLUGIN_NAME)
|
||||
offer.primaryActionText shouldNotBe INSTALL_LABEL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The click asks the enabler to load the descriptor, and it asks for a plugin that is already
|
||||
* switched on as well. `DynamicPluginEnabler.enable` answers true at once when everything is
|
||||
* loaded and attempts the load otherwise, so one call covers both on-disk states; skipping it for
|
||||
* a switched-on plugin would send a user to a restart the session does not need.
|
||||
*
|
||||
* `PluginEnabler` is an application service, replaced here so that nothing is loaded into the
|
||||
* test application. Its answer is what decides the branch `accept()` takes next.
|
||||
*/
|
||||
fun `test accepting asks the enabler to load a plugin on disk that is switched on`() {
|
||||
val enabler = RecordingPluginEnabler(loaded = true)
|
||||
ApplicationManager.getApplication().replaceService(PluginEnabler::class.java, enabler, testRootDisposable)
|
||||
|
||||
withFigmaConnectOnDiskAndSwitchedOn {
|
||||
val offer = offerForTest()
|
||||
// Checked before the click, so a mutation that reverts the label predicate fails here rather
|
||||
// than in `installAndEnable`, which goes to Marketplace.
|
||||
offer.primaryActionText shouldNotBe INSTALL_LABEL
|
||||
offer.accept()
|
||||
}
|
||||
|
||||
enabler.enabled shouldBe listOf(FIGMA_CONNECT_PLUGIN_ID)
|
||||
}
|
||||
|
||||
/** The dismissal records what the user answered and nothing else. */
|
||||
fun `test the dismissed event names the Markdown trigger`() {
|
||||
val panel = bannerOver(designNote())
|
||||
|
||||
val events = FUCollectorTestCase.collectLogEvents(testRootDisposable) {
|
||||
panel.findLabelByName(DISMISS_LABEL)!!.doClick()
|
||||
}
|
||||
|
||||
val dismissed = events.single {
|
||||
it.group.id == FigmaAdvertiserUsagesCollector.GROUP_ID && it.event.id == "suggestion.dismissed"
|
||||
}
|
||||
dismissed.event.data["trigger"] shouldBe "MARKDOWN_FIGMA_LINK"
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [body] with a plugin set in which Figma Connect's id is installed and not enabled, which
|
||||
* leaves `DisabledPluginsState` untouched — so the plugin reads as on disk, switched on, and not
|
||||
* loaded. `PluginSet`'s constructor is `internal`, so `PluginSetTestBuilder` is the way in, and
|
||||
* the set is application-wide, so the original goes back in a `finally`.
|
||||
*/
|
||||
private fun <T> withFigmaConnectOnDiskAndSwitchedOn(body: () -> T): T {
|
||||
val pluginsDir = FileUtil.createTempDirectory("figmaAdvertiserTest", "unloaded", true).toPath()
|
||||
plugin(FIGMA_CONNECT_PLUGIN_ID) { name = FIGMA_CONNECT_PLUGIN_NAME }.installAt(pluginsDir)
|
||||
val originalPluginSet = PluginManagerCore.getPluginSet()
|
||||
PluginManagerCore.setPluginSet(
|
||||
PluginSetTestBuilder.fromPath(pluginsDir)
|
||||
.withDisabledPlugins(FIGMA_CONNECT_PLUGIN_ID)
|
||||
.buildState(configureClassLoaders = false).pluginSet
|
||||
)
|
||||
try {
|
||||
return body()
|
||||
}
|
||||
finally {
|
||||
PluginManagerCore.setPluginSet(originalPluginSet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun offerForTest(): FigmaSuggestionOffer = FigmaSuggestionOffer(
|
||||
project,
|
||||
FigmaAdvertiserUsagesCollector.SuggestionTrigger.MARKDOWN_FIGMA_LINK,
|
||||
FigmaAdvertiserUsagesCollector.SuggestionSurface.EDITOR,
|
||||
)
|
||||
|
||||
/** Records what `accept()` asks of the platform, and loads nothing into the test application. */
|
||||
private class RecordingPluginEnabler(private val loaded: Boolean) : PluginEnabler {
|
||||
val enabled: MutableList<String> = mutableListOf()
|
||||
|
||||
override fun isDisabled(pluginId: PluginId): Boolean = false
|
||||
|
||||
override fun enable(descriptors: Collection<IdeaPluginDescriptor>): Boolean {
|
||||
descriptors.mapTo(enabled) { it.pluginId.idString }
|
||||
return loaded
|
||||
}
|
||||
|
||||
override fun disable(descriptors: Collection<IdeaPluginDescriptor>): Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a plugin carrying Figma Connect's id, and answers the `Disposable` that takes it away
|
||||
* again. It declares only a dependency on the platform's language modules, so the id is the whole
|
||||
* of what this fixture shares with the real plugin — which is all either test reads.
|
||||
*/
|
||||
private fun loadFigmaConnectId(): Disposable = loadPluginWithText(
|
||||
pluginSpec = plugin(FIGMA_CONNECT_PLUGIN_ID) { dependsIntellijModulesLang() },
|
||||
pluginsDir = FileUtil.createTempDirectory("figmaAdvertiserTest", "plugins", true).toPath(),
|
||||
)
|
||||
|
||||
private fun designNote(): PsiFile = myFixture.configureByText(
|
||||
"design.md",
|
||||
"# Checkout\n\nThe spec is at https://www.figma.com/design/AbC123/Checkout?node-id=1-2 .\n",
|
||||
)
|
||||
|
||||
private fun suggestionOver(file: PsiFile): PluginSuggestion? =
|
||||
FigmaConnectPluginSuggestionProvider().getSuggestion(project, file.virtualFile)
|
||||
|
||||
private fun bannerOver(file: PsiFile): EditorNotificationPanel =
|
||||
panelOf(suggestionOver(file).shouldNotBeNull())
|
||||
|
||||
private fun panelOf(suggestion: PluginSuggestion): EditorNotificationPanel {
|
||||
val panel = suggestion.apply(TextEditorProvider.getInstance().getTextEditor(myFixture.editor))
|
||||
panel.shouldNotBeNull()
|
||||
return panel
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PLUGIN_SUGGESTION_EP: ExtensionPointName<PluginSuggestionProvider> =
|
||||
ExtensionPointName("com.intellij.pluginSuggestionProvider")
|
||||
|
||||
val INSTALL_LABEL: String =
|
||||
IdeBundle.message("plugins.advertiser.action.install.plugin.name", FIGMA_CONNECT_PLUGIN_NAME)
|
||||
val DISMISS_LABEL: String = IdeBundle.message("plugins.advertiser.action.ignore.ultimate")
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package org.intellij.plugins.markdown.figmaAdvertiser
|
||||
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaAdvertiserRegistry
|
||||
import com.intellij.markdown.figmaAdvertiser.FigmaConnectPluginSuggestionProvider
|
||||
import com.intellij.markdown.figmaAdvertiser.containsFigmaUrl
|
||||
import com.intellij.markdown.figmaAdvertiser.isMarkdownSuggestionFile
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.Test
|
||||
import org.w3c.dom.Element
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/**
|
||||
* The questions the banner answers before it draws anything, asked without a project and without a
|
||||
* Swing component.
|
||||
*/
|
||||
class MarkdownFigmaAdvertiserDecisionTest {
|
||||
|
||||
@Test
|
||||
fun `a design link in prose is a Figma link`() {
|
||||
containsFigmaUrl("The spec is at https://www.figma.com/design/AbC123/Checkout?node-id=1-2 .").shouldBeTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file link and a proto link are Figma links`() {
|
||||
containsFigmaUrl("[spec](https://figma.com/file/AbC123/Checkout)").shouldBeTrue()
|
||||
containsFigmaUrl("[flow](http://www.figma.com/proto/AbC123/Checkout)").shouldBeTrue()
|
||||
}
|
||||
|
||||
/** The word is not the link. A page that talks about Figma has not shared a design. */
|
||||
@Test
|
||||
fun `the word figma alone is not a Figma link`() {
|
||||
containsFigmaUrl("We moved the design system to Figma last year.").shouldBeFalse()
|
||||
containsFigmaUrl("See https://www.figma.com/ for the product.").shouldBeFalse()
|
||||
containsFigmaUrl("Read https://www.figma.com/blog/why-we-did-it").shouldBeFalse()
|
||||
}
|
||||
|
||||
/** A host that merely ends in the name belongs to somebody else. */
|
||||
@Test
|
||||
fun `a look-alike host is not a Figma link`() {
|
||||
containsFigmaUrl("https://notfigma.com/design/AbC123/Checkout").shouldBeFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the extensions the Markdown file type declares are the ones that are looked into`() {
|
||||
isMarkdownSuggestionFile("/src/docs/README.md").shouldBeTrue()
|
||||
isMarkdownSuggestionFile("/src/docs/README.markdown").shouldBeTrue()
|
||||
isMarkdownSuggestionFile("/src/docs/rules.mdc").shouldBeTrue()
|
||||
// A file system keeps the case a user typed.
|
||||
isMarkdownSuggestionFile("/src/docs/README.MD").shouldBeTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another extension is not looked into`() {
|
||||
isMarkdownSuggestionFile("/src/docs/README.txt").shouldBeFalse()
|
||||
isMarkdownSuggestionFile("/src/App.tsx").shouldBeFalse()
|
||||
// A directory named like the extension is not a file with it.
|
||||
isMarkdownSuggestionFile("/src/md/notes").shouldBeFalse()
|
||||
}
|
||||
|
||||
/**
|
||||
* The default is written twice, and a running IDE reads the declaration rather than the constant.
|
||||
* Read here from the descriptor's own text, so a change to one of the two fails this.
|
||||
*/
|
||||
@Test
|
||||
fun `the registry key default matches the descriptor declaration`() {
|
||||
val descriptor = FigmaConnectPluginSuggestionProvider::class.java
|
||||
.getResourceAsStream("/intellij.markdown.figmaAdvertiser.xml")
|
||||
.shouldNotBeNull()
|
||||
|
||||
val declaration = descriptor.use { DocumentBuilderFactory.newDefaultInstance().newDocumentBuilder().parse(it) }
|
||||
.getElementsByTagName("registryKey")
|
||||
.let { keys -> (0 until keys.length).map { keys.item(it) as Element } }
|
||||
.single { it.getAttribute("key") == FigmaAdvertiserRegistry.KEY_ADVERTISER_ENABLED }
|
||||
|
||||
declaration.getAttribute("defaultValue") shouldBe FigmaAdvertiserRegistry.ENABLED_BY_DEFAULT.toString()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user