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() + } +}