diff --git a/.idea/modules.xml b/.idea/modules.xml
index e37fe7aa6364..b03321235810 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -1141,6 +1141,7 @@
+
diff --git a/build/bazel-generated-file-list.txt b/build/bazel-generated-file-list.txt
index 742e127bee16..a75c1b14518c 100644
--- a/build/bazel-generated-file-list.txt
+++ b/build/bazel-generated-file-list.txt
@@ -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
diff --git a/plugins/markdown/core/plugin-content.yaml b/plugins/markdown/core/plugin-content.yaml
index bb79263bc46e..62724ebf84e7 100644
--- a/plugins/markdown/core/plugin-content.yaml
+++ b/plugins/markdown/core/plugin-content.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
diff --git a/plugins/markdown/core/resources/META-INF/plugin.xml b/plugins/markdown/core/resources/META-INF/plugin.xml
index f10004a32d1c..13f527fb8ffd 100644
--- a/plugins/markdown/core/resources/META-INF/plugin.xml
+++ b/plugins/markdown/core/resources/META-INF/plugin.xml
@@ -30,6 +30,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/markdown/figmaAdvertiser/resources/intellij.markdown.figmaAdvertiser.xml b/plugins/markdown/figmaAdvertiser/resources/intellij.markdown.figmaAdvertiser.xml
new file mode 100644
index 000000000000..e811c837124c
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/resources/intellij.markdown.figmaAdvertiser.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/markdown/figmaAdvertiser/resources/messages/MarkdownFigmaAdvertiserBundle.properties b/plugins/markdown/figmaAdvertiser/resources/messages/MarkdownFigmaAdvertiserBundle.properties
new file mode 100644
index 000000000000..1c628fae2442
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/resources/messages/MarkdownFigmaAdvertiserBundle.properties
@@ -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}
diff --git a/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaAdvertiserRegistry.kt b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaAdvertiserRegistry.kt
new file mode 100644
index 000000000000..583c945999e8
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaAdvertiserRegistry.kt
@@ -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 ``
+ * 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)
+}
diff --git a/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaAdvertiserUsagesCollector.kt b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaAdvertiserUsagesCollector.kt
new file mode 100644
index 000000000000..7e5b8122bc3d
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaAdvertiserUsagesCollector.kt
@@ -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("trigger")
+ private val SURFACE = EventFields.Enum("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().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> =
+ 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()
+
+ /** 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,
+ )
+}
diff --git a/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaConnectPluginSuggestionProvider.kt b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaConnectPluginSuggestionProvider.kt
new file mode 100644
index 000000000000..b3d1cfb6293f
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaConnectPluginSuggestionProvider.kt
@@ -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,
+ 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"
diff --git a/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaLinkScan.kt b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaLinkScan.kt
new file mode 100644
index 000000000000..a6c5504cedb6
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaLinkScan.kt
@@ -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> =
+ 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
diff --git a/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaSuggestionDecision.kt b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaSuggestionDecision.kt
new file mode 100644
index 000000000000..1be26a4275bd
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaSuggestionDecision.kt
@@ -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 = 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 ``, which the platform's Install action label is built from. */
+@ApiStatus.Internal
+const val FIGMA_CONNECT_PLUGIN_NAME: String = "Figma Connect"
diff --git a/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaSuggestionDismissal.kt b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaSuggestionDismissal.kt
new file mode 100644
index 000000000000..d8dc7464c9ed
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaSuggestionDismissal.kt
@@ -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)
+}
diff --git a/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaSuggestionOffer.kt b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaSuggestionOffer.kt
new file mode 100644
index 000000000000..27438e46f5ea
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/FigmaSuggestionOffer.kt
@@ -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"
diff --git a/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/MarkdownFigmaAdvertiserBundle.kt b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/MarkdownFigmaAdvertiserBundle.kt
new file mode 100644
index 000000000000..4224ffc28e85
--- /dev/null
+++ b/plugins/markdown/figmaAdvertiser/src/com/intellij/markdown/figmaAdvertiser/MarkdownFigmaAdvertiserBundle.kt
@@ -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)
+}
diff --git a/plugins/markdown/test/BUILD.bazel b/plugins/markdown/test/BUILD.bazel
index 91647cbb410e..da16f674070d 100644
--- a/plugins/markdown/test/BUILD.bazel
+++ b/plugins/markdown/test/BUILD.bazel
@@ -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
diff --git a/plugins/markdown/test/intellij.markdown.tests.iml b/plugins/markdown/test/intellij.markdown.tests.iml
index 8a8b99f65f67..9bff253b09da 100644
--- a/plugins/markdown/test/intellij.markdown.tests.iml
+++ b/plugins/markdown/test/intellij.markdown.tests.iml
@@ -77,5 +77,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/markdown/test/src/org/intellij/plugins/markdown/figmaAdvertiser/MarkdownFigmaAdvertiserBannerTest.kt b/plugins/markdown/test/src/org/intellij/plugins/markdown/figmaAdvertiser/MarkdownFigmaAdvertiserBannerTest.kt
new file mode 100644
index 000000000000..f579fe1bfee7
--- /dev/null
+++ b/plugins/markdown/test/src/org/intellij/plugins/markdown/figmaAdvertiser/MarkdownFigmaAdvertiserBannerTest.kt
@@ -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 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 = mutableListOf()
+
+ override fun isDisabled(pluginId: PluginId): Boolean = false
+
+ override fun enable(descriptors: Collection): Boolean {
+ descriptors.mapTo(enabled) { it.pluginId.idString }
+ return loaded
+ }
+
+ override fun disable(descriptors: Collection): 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 =
+ 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")
+ }
+}
diff --git a/plugins/markdown/test/src/org/intellij/plugins/markdown/figmaAdvertiser/MarkdownFigmaAdvertiserDecisionTest.kt b/plugins/markdown/test/src/org/intellij/plugins/markdown/figmaAdvertiser/MarkdownFigmaAdvertiserDecisionTest.kt
new file mode 100644
index 000000000000..a6a6750c413b
--- /dev/null
+++ b/plugins/markdown/test/src/org/intellij/plugins/markdown/figmaAdvertiser/MarkdownFigmaAdvertiserDecisionTest.kt
@@ -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()
+ }
+}