From aae05ffee98cdafc8deb3aba57d699adda70df89 Mon Sep 17 00:00:00 2001 From: Alexander Koshevoy Date: Wed, 15 Jul 2026 14:24:29 +0200 Subject: [PATCH] IJPL-246739 Add in-IDE WSL user satisfaction survey Adds a feedback survey targeted at users actively working with WSL to measure satisfaction with WSL support (performance and freezes in particular) and gather areas for improvement. `WslSatisfactionSurveyStore` persists a distinct-days counter (incremented at most once per day by `WslSatisfactionSurveyProjectActivity` on WSL project open) and the first-WSL-use timestamp; `shouldShowDialog()` requires the project to have been opened in WSL on at least 3 distinct days and at least 3 days since first WSL use, matching the "opened in WSL x3 / after 3-5 days" brief. The survey shows only on Windows in IDEA, PyCharm, WebStorm, PhpStorm and GoLand, only for version 2026.2(.x), and only for existing users (`InitialConfigImportState.isNewUser()` is excluded). Feedback auto-attaches IDE name, WSL distribution and version, and whether WSL runs via IJent; freezes are covered by the survey questions rather than auto-detected. Registry keys `wsl.survey.first.use.date` and `wsl.survey.days.with.wsl` allow testing the show conditions, and an internal action shows the notification (and, via its "Respond" action, the dialog) as a test request. The dialog uses the platform feedback UI blocks: a star `RatingBlock` for overall satisfaction and a star `RatingGroupBlock` for stability, responsiveness and overall quality, dropdowns for the two freeze-frequency questions, and a two-column checkbox group for where freezes occur. To support this, `RatingBlock` gains an optional hint, `ComboBoxBlock` gains `useWrappingLabel()` so a long question wraps instead of stretching the dialog, and `CheckBoxGroupBlock` gains `setColumnCount()` to lay options out over several columns (isolated in its own grid so it does not affect sibling blocks). (cherry picked from commit 52635e9c8bd195ba3f97ef5f9f2f5cec72588d22) IJ-MR-214055 GitOrigin-RevId: c5ee3239cfa3463e846173c758652c0615677fb8 --- platform/feedback/BUILD.bazel | 2 + .../feedback/intellij.platform.feedback.iml | 1 + .../resources/intellij.platform.feedback.xml | 13 ++ .../WslSatisfactionFeedbackBundle.properties | 51 +++++ .../dialog/uiBlocks/CheckBoxGroupBlock.kt | 63 +++++-- .../feedback/dialog/uiBlocks/ComboBoxBlock.kt | 25 ++- .../feedback/dialog/uiBlocks/RatingBlock.kt | 28 ++- .../wsl/ShowWslSatisfactionDialogAction.kt | 27 +++ .../wsl/WslSatisfactionFeedbackBundle.kt | 20 ++ .../wsl/WslSatisfactionFeedbackDialog.kt | 176 ++++++++++++++++++ .../feedback/wsl/WslSatisfactionSurvey.kt | 12 ++ .../wsl/WslSatisfactionSurveyConfig.kt | 72 +++++++ .../WslSatisfactionSurveyProjectActivity.kt | 16 ++ .../wsl/WslSatisfactionSurveyStore.kt | 106 +++++++++++ .../feedback/wsl/WslSatisfactionSystemData.kt | 25 +++ 15 files changed, 620 insertions(+), 17 deletions(-) create mode 100644 platform/feedback/resources/messages/WslSatisfactionFeedbackBundle.properties create mode 100644 platform/feedback/src/com/intellij/platform/feedback/wsl/ShowWslSatisfactionDialogAction.kt create mode 100644 platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionFeedbackBundle.kt create mode 100644 platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionFeedbackDialog.kt create mode 100644 platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurvey.kt create mode 100644 platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyConfig.kt create mode 100644 platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyProjectActivity.kt create mode 100644 platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyStore.kt create mode 100644 platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSystemData.kt diff --git a/platform/feedback/BUILD.bazel b/platform/feedback/BUILD.bazel index a643cad38a05..46ec41344d2f 100644 --- a/platform/feedback/BUILD.bazel +++ b/platform/feedback/BUILD.bazel @@ -21,6 +21,7 @@ jvm_library( deps = [ # do not sort, "//platform/core-api:core", + "//platform/execution", "//platform/projectModel-api:projectModel", "//platform/util", "//platform/editor-ui-api:editor-ui", @@ -56,6 +57,7 @@ jvm_library( # do not sort, ":feedback", "//platform/core-api:core_test_lib", + "//platform/execution:execution_test_lib", "//platform/projectModel-api:projectModel_test_lib", "//platform/util:util_test_lib", "//platform/editor-ui-api:editor-ui_test_lib", diff --git a/platform/feedback/intellij.platform.feedback.iml b/platform/feedback/intellij.platform.feedback.iml index 26d7552998ed..aeb62ef67520 100644 --- a/platform/feedback/intellij.platform.feedback.iml +++ b/platform/feedback/intellij.platform.feedback.iml @@ -30,6 +30,7 @@ + diff --git a/platform/feedback/resources/intellij.platform.feedback.xml b/platform/feedback/resources/intellij.platform.feedback.xml index 2310c814a50c..b8f0b3205da5 100644 --- a/platform/feedback/resources/intellij.platform.feedback.xml +++ b/platform/feedback/resources/intellij.platform.feedback.xml @@ -8,6 +8,7 @@ + @@ -69,6 +70,14 @@ + + + + + + @@ -78,6 +87,7 @@ + @@ -96,6 +106,9 @@ + diff --git a/platform/feedback/resources/messages/WslSatisfactionFeedbackBundle.properties b/platform/feedback/resources/messages/WslSatisfactionFeedbackBundle.properties new file mode 100644 index 000000000000..e3e94baa122e --- /dev/null +++ b/platform/feedback/resources/messages/WslSatisfactionFeedbackBundle.properties @@ -0,0 +1,51 @@ +action.ShowWslSatisfactionDialog.text=Show WSL Satisfaction Notification + +wsl.satisfaction.notification.request.title=WSL feedback +wsl.satisfaction.notification.request.content=Please help us improve the WSL experience by taking our short survey. + +wsl.satisfaction.dialog.top.title=WSL Support Experience Survey +wsl.satisfaction.dialog.title=WSL support experience survey +wsl.satisfaction.dialog.description=We are working on improving WSL support in {0} and would like to hear about your recent experience. + +wsl.satisfaction.dialog.rating.label=How satisfied are you with WSL support? +wsl.satisfaction.dialog.rating.hint=Where 1 is "very dissatisfied" and 5 is "very satisfied". + +wsl.satisfaction.dialog.areas.label=How would you rate the following aspects of WSL support? +wsl.satisfaction.dialog.areas.hint=Where 1 is "poor" and 5 is "excellent". +wsl.satisfaction.dialog.area.stability=Stability (freezes) +wsl.satisfaction.dialog.area.responsiveness=Responsiveness +wsl.satisfaction.dialog.area.quality=Overall quality + +wsl.satisfaction.dialog.freeze.comparison.label=Compared to your previous experience, how often does the IDE freeze, hang, or become unresponsive when working with WSL in the current version? +wsl.satisfaction.dialog.freeze.comparison.less=Less often +wsl.satisfaction.dialog.freeze.comparison.same=About the same as before +wsl.satisfaction.dialog.freeze.comparison.more=More often +wsl.satisfaction.dialog.freeze.comparison.unsure=Hard to say + +wsl.satisfaction.dialog.freeze.frequency.label=How often do you currently experience IDE freezes when working with WSL? +wsl.satisfaction.dialog.freeze.frequency.several.day=Several times a day +wsl.satisfaction.dialog.freeze.frequency.once.day=About once a day +wsl.satisfaction.dialog.freeze.frequency.several.week=Several times a week +wsl.satisfaction.dialog.freeze.frequency.monthly=Once or several times a month +wsl.satisfaction.dialog.freeze.frequency.rarely=Rarely or almost never + +wsl.satisfaction.dialog.freeze.situations.label=When do freezes usually occur, if at all? +wsl.satisfaction.dialog.freeze.situations.none=I don't encounter freezes +wsl.satisfaction.dialog.freeze.situations.opening=Opening a WSL project +wsl.satisfaction.dialog.freeze.situations.indexing=Indexing +wsl.satisfaction.dialog.freeze.situations.running=Running or debugging +wsl.satisfaction.dialog.freeze.situations.terminal=Using the terminal +wsl.satisfaction.dialog.freeze.situations.vcs=Git/VCS operations +wsl.satisfaction.dialog.freeze.situations.docker=Docker or containers +wsl.satisfaction.dialog.freeze.situations.editing=Editing files +wsl.satisfaction.dialog.freeze.situations.branches=Switching branches +wsl.satisfaction.dialog.freeze.situations.search=Searching/navigation + +wsl.satisfaction.dialog.additional.label=Is there anything else you would like to tell us about WSL support in {0}? + +wsl.satisfaction.thanks.feedback.content=Thank you for your feedback! It will help us improve WSL support. + +wsl.satisfaction.system.info.ide.name=IDE name: +wsl.satisfaction.system.info.distribution=WSL distribution: +wsl.satisfaction.system.info.wsl.version=WSL version: +wsl.satisfaction.system.info.eel.api.used=WSL via IJent: diff --git a/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/CheckBoxGroupBlock.kt b/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/CheckBoxGroupBlock.kt index 2e44f25c59bf..cbb7d5d6266c 100644 --- a/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/CheckBoxGroupBlock.kt +++ b/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/CheckBoxGroupBlock.kt @@ -11,6 +11,7 @@ import com.intellij.ui.dsl.builder.BottomGap import com.intellij.ui.dsl.builder.Cell import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.Row import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.layout.selected @@ -34,6 +35,7 @@ class CheckBoxGroupBlock( private var myIncludeOtherTextField = false private var myOtherProperty: String = "" private var myOtherTextfieldPlaceholderText: String = "" + private var myColumnCount: Int = 1 private var otherCheckBox: JBCheckBox? = null private var otherTextField: JBTextField? = null @@ -41,23 +43,31 @@ class CheckBoxGroupBlock( override fun addToPanel(panel: Panel) { val allCheckBoxes = mutableListOf() - panel.apply { - buttonsGroup(indent = false) { + fun Row.addCheckBox(itemData: CheckBoxItemData) { + allCheckBoxes += checkBox(itemData.label) + .bindSelected({ itemData.property }, { itemData.property = it }) + .addChooseOptionValidation(allCheckBoxes) + .component + } + + val itemRows = myItemsData.chunked(myColumnCount) + + val groupContent: (Panel) -> Unit = { container -> + container.buttonsGroup(indent = false) { row { label(myGroupLabel) .bold() }.bottomGap(BottomGap.NONE) - myItemsData.forEachIndexed { i, itemData -> - row { - allCheckBoxes += checkBox(itemData.label).bindSelected( - { myItemsData[i].property }, - { myItemsData[i].property = it } - ).addChooseOptionValidation(allCheckBoxes) - .component - }.apply { - if (i == myItemsData.size - 1 && !myIncludeOtherTextField) { - this.bottomGap(BottomGap.MEDIUM) - } + itemRows.forEachIndexed { rowIndex, rowItems -> + // Use the standard multi-column DSL helpers so the checkboxes align in evenly spaced columns. + val cells: List<(Row.() -> Unit)?> = rowItems.map { itemData -> { addCheckBox(itemData) } } + val addedRow = when (myColumnCount) { + 2 -> twoColumnsRow(cells.getOrNull(0), cells.getOrNull(1)) + 3 -> threeColumnsRow(cells.getOrNull(0), cells.getOrNull(1), cells.getOrNull(2)) + else -> row { rowItems.forEach { addCheckBox(it) } } + } + if (rowIndex == itemRows.lastIndex && !myIncludeOtherTextField) { + addedRow.bottomGap(BottomGap.MEDIUM) } } @@ -109,6 +119,17 @@ class CheckBoxGroupBlock( } } } + + panel.apply { + if (myColumnCount > 1) { + // Isolate the multi-column grid in its own sub-panel so its wide columns do not force sibling + // blocks (e.g. rating rows) to align with them. + panel { groupContent(this) } + } + else { + groupContent(this) + } + } } override fun collectBlockTextDescription(stringBuilder: StringBuilder) { @@ -151,6 +172,22 @@ class CheckBoxGroupBlock( return this } + /** + * Lays the checkboxes out over the given number of [columnCount] columns (1, 2 or 3) to keep tall option + * lists compact, using the standard [Panel.twoColumnsRow]/[Panel.threeColumnsRow] layout. + * + * Follow the IntelliJ UI guidelines when choosing the value: + * arrange checkboxes with labels of up to 30 characters in 2 columns, + * and checkboxes with labels of up to 15 characters in 3 columns. + * + * @see Layout guidelines + */ + fun setColumnCount(columnCount: Int): CheckBoxGroupBlock { + require(columnCount in 1..3) { "columnCount must be 1, 2 or 3, but was $columnCount" } + myColumnCount = columnCount + return this + } + private fun Cell.addChooseOptionValidation(allCheckBoxes: List): Cell { if (requireAnswer) { val errorMessage = if (myIncludeOtherTextField) { diff --git a/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/ComboBoxBlock.kt b/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/ComboBoxBlock.kt index ff118f0c9ecf..7d2ffffb302b 100644 --- a/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/ComboBoxBlock.kt +++ b/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/ComboBoxBlock.kt @@ -8,10 +8,12 @@ import com.intellij.platform.feedback.impl.bundle.CommonFeedbackBundle import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.BottomGap import com.intellij.ui.dsl.builder.LabelPosition +import com.intellij.ui.dsl.builder.MAX_LINE_LENGTH_WORD_WRAP import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.bindItem import com.intellij.ui.dsl.builder.columns import com.intellij.ui.dsl.builder.toMutableProperty +import com.intellij.xml.util.XmlStringUtil import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.put @@ -24,14 +26,26 @@ class ComboBoxBlock(@NlsContexts.Label private val myLabel: String, private var myColumnSize: Int = COMBOBOX_COLUMN_SIZE private var myRandomizeOptionOrder: Boolean = false private var myUseAlignFill: Boolean = false + private var myUseWrappingLabel: Boolean = false override fun addToPanel(panel: Panel) { val items = if (myRandomizeOptionOrder) myItems.shuffled() else myItems panel.apply { + // A long label rendered as a single-line JBLabel forces the whole dialog to become very wide, + // so it can optionally be shown as a word-wrapped label above the combo box instead. + if (myUseWrappingLabel) { + row { + text("${XmlStringUtil.escapeString(myLabel)}", maxLineLength = MAX_LINE_LENGTH_WORD_WRAP) + }.bottomGap(BottomGap.NONE) + } row { comboBox(items) - .label(createBoldJBLabel(myLabel), LabelPosition.TOP) + .apply { + if (!myUseWrappingLabel) { + label(createBoldJBLabel(myLabel), LabelPosition.TOP) + } + } .bindItem(::myProperty.toMutableProperty()) .apply { if (myUseAlignFill) { @@ -82,6 +96,15 @@ class ComboBoxBlock(@NlsContexts.Label private val myLabel: String, return this } + /** + * Renders the label as a word-wrapped text above the combo box instead of a single-line label, + * so a long question does not stretch the whole dialog horizontally. + */ + fun useWrappingLabel(): ComboBoxBlock { + myUseWrappingLabel = true + return this + } + fun randomizeOptionOrder(): ComboBoxBlock { myRandomizeOptionOrder = true return this diff --git a/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/RatingBlock.kt b/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/RatingBlock.kt index 0ed838ea58d6..9744074d928e 100644 --- a/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/RatingBlock.kt +++ b/platform/feedback/src/com/intellij/platform/feedback/dialog/uiBlocks/RatingBlock.kt @@ -12,18 +12,35 @@ import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.Row import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.put +import org.jetbrains.annotations.Nls -class RatingBlock(@NlsContexts.Label private val myLabel: String, - private val myJsonElementName: String) : FeedbackBlock, TextDescriptionProvider, JsonDataProvider { +class RatingBlock( + @NlsContexts.Label private val myLabel: String, + private val myJsonElementName: String, +) : FeedbackBlock, TextDescriptionProvider, JsonDataProvider { private var myProperty: Int = 0 private var requireAnswer: Boolean = true + private var myHint: @Nls String? = null override fun addToPanel(panel: Panel) { + val hint = myHint panel.apply { + // With a hint, show the label and the hint above the rating (like RatingGroupBlock); + // otherwise keep the label attached on top of the rating for backward compatibility. + if (hint != null) { + row { + label(myLabel).bold() + rowComment(hint) + }.bottomGap(BottomGap.NONE) + } row { rating() - .label(createBoldJBLabel(myLabel), LabelPosition.TOP) + .apply { + if (hint == null) { + label(createBoldJBLabel(myLabel), LabelPosition.TOP) + } + } .apply { onApply { myProperty = this.component.myRating @@ -57,6 +74,11 @@ class RatingBlock(@NlsContexts.Label private val myLabel: String, requireAnswer = false return this } + + fun setHint(@Nls hint: String): RatingBlock { + myHint = hint + return this + } } internal fun Row.rating(): Cell { diff --git a/platform/feedback/src/com/intellij/platform/feedback/wsl/ShowWslSatisfactionDialogAction.kt b/platform/feedback/src/com/intellij/platform/feedback/wsl/ShowWslSatisfactionDialogAction.kt new file mode 100644 index 000000000000..16813201d4c4 --- /dev/null +++ b/platform/feedback/src/com/intellij/platform/feedback/wsl/ShowWslSatisfactionDialogAction.kt @@ -0,0 +1,27 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.feedback.wsl + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import java.util.function.Supplier + +/** + * Shows the WSL satisfaction survey notification (bypassing the usual show conditions) so QA can check + * both the notification wording and, via the "Respond" action, the dialog. Feedback is sent as a test request. + */ +@Suppress("ActionPresentationInstantiatedInCtor") +internal class ShowWslSatisfactionDialogAction : + DumbAwareAction(Supplier { WslSatisfactionFeedbackBundle.message("action.ShowWslSatisfactionDialog.text") }) { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + WslSatisfactionSurvey().showNotification(project, forTest = true) + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = e.project != null + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} diff --git a/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionFeedbackBundle.kt b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionFeedbackBundle.kt new file mode 100644 index 000000000000..e035a9c555a6 --- /dev/null +++ b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionFeedbackBundle.kt @@ -0,0 +1,20 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.feedback.wsl + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +internal object WslSatisfactionFeedbackBundle { + + @NonNls + private const val BUNDLE = "messages.WslSatisfactionFeedbackBundle" + + private val INSTANCE = DynamicBundle(WslSatisfactionFeedbackBundle::class.java, BUNDLE) + + @Nls + fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String { + return INSTANCE.getMessage(key, *params) + } +} diff --git a/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionFeedbackDialog.kt b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionFeedbackDialog.kt new file mode 100644 index 000000000000..a75759c2c0b1 --- /dev/null +++ b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionFeedbackDialog.kt @@ -0,0 +1,176 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.feedback.wsl + +import com.intellij.execution.wsl.WSLDistribution +import com.intellij.execution.wsl.WslDistributionManager +import com.intellij.execution.wsl.WslIjentAvailabilityService +import com.intellij.openapi.application.ApplicationNamesInfo +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.WindowManager +import com.intellij.platform.feedback.dialog.BlockBasedFeedbackDialogWithEmail +import com.intellij.platform.feedback.dialog.CommonFeedbackSystemData +import com.intellij.platform.feedback.dialog.showFeedbackSystemInfoDialog +import com.intellij.platform.feedback.dialog.uiBlocks.CheckBoxGroupBlock +import com.intellij.platform.feedback.dialog.uiBlocks.CheckBoxItemData +import com.intellij.platform.feedback.dialog.uiBlocks.ComboBoxBlock +import com.intellij.platform.feedback.dialog.uiBlocks.DescriptionBlock +import com.intellij.platform.feedback.dialog.uiBlocks.FeedbackBlock +import com.intellij.platform.feedback.dialog.uiBlocks.RatingBlock +import com.intellij.platform.feedback.dialog.uiBlocks.RatingGroupBlock +import com.intellij.platform.feedback.dialog.uiBlocks.RatingItem +import com.intellij.platform.feedback.dialog.uiBlocks.TextAreaBlock +import com.intellij.platform.feedback.dialog.uiBlocks.TopLabelBlock +import com.intellij.platform.feedback.impl.notification.ThanksForFeedbackNotification +import com.intellij.ui.ScreenUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.Dimension +import java.awt.Rectangle +import java.nio.file.Path +import javax.swing.JComponent + +internal class WslSatisfactionFeedbackDialog( + private val project: Project, + forTest: Boolean, +) : BlockBasedFeedbackDialogWithEmail(project, forTest) { + + /** Increase the additional number when feedback format is changed */ + override val myFeedbackJsonVersion: Int = super.myFeedbackJsonVersion + 1 + + override val myFeedbackReportId: String = "wsl_satisfaction" + override val zendeskTicketTitle: String = "WSL Support in-IDE Feedback" + override val zendeskFeedbackType: String = "WSL Support Feedback" + override fun shouldAutoCloseZendeskTicket(): Boolean = false + + private val ideName: String = ApplicationNamesInfo.getInstance().fullProductName + + override suspend fun computeSystemInfoData(): WslSatisfactionSystemData { + val distribution = withContext(Dispatchers.IO) { findWslDistribution(project.basePath) } + val isEelApiUsed = WslIjentAvailabilityService.getInstance().runWslCommandsViaIjent() + return WslSatisfactionSystemData( + ideName = ideName, + wslDistribution = distribution?.presentableName ?: "UNKNOWN", + wslVersion = distribution?.version ?: -1, + isEelApiUsed = isEelApiUsed, + commonData = CommonFeedbackSystemData.getCurrentData(), + ) + } + + private fun findWslDistribution(projectPath: String?): WSLDistribution? { + if (projectPath == null) return null + return WslDistributionManager.getInstance().installedDistributions.firstOrNull { + runCatching { Path.of(projectPath).startsWith(Path.of(it.getUNCRootPath().toUri())) }.getOrDefault(false) + } + } + + override fun showFeedbackSystemInfoDialog(systemInfoData: WslSatisfactionSystemData) { + showFeedbackSystemInfoDialog(myProject, systemInfoData.commonData) { + row(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.system.info.ide.name")) { + label(systemInfoData.ideName) + } + row(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.system.info.distribution")) { + label(systemInfoData.wslDistribution) + } + row(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.system.info.wsl.version")) { + label(systemInfoData.wslVersion.toString()) + } + row(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.system.info.eel.api.used")) { + label(systemInfoData.isEelApiUsed.toString()) + } + } + } + + override val myTitle: String = WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.top.title") + + override val myBlocks: List = listOf( + TopLabelBlock(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.title")), + DescriptionBlock(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.description", ideName)), + + RatingBlock( + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.rating.label"), + "satisfaction", + ).setHint(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.rating.hint")), + + RatingGroupBlock( + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.areas.label"), + listOf( + RatingItem(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.area.stability"), "stability"), + RatingItem(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.area.responsiveness"), "responsiveness"), + RatingItem(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.area.quality"), "overall_quality"), + ), + ).setHint(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.areas.hint")), + + ComboBoxBlock( + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.comparison.label"), + listOf( + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.comparison.less"), + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.comparison.same"), + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.comparison.more"), + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.comparison.unsure"), + ), + "freeze_comparison", + ).useWrappingLabel(), + + ComboBoxBlock( + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.frequency.label"), + listOf( + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.frequency.several.day"), + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.frequency.once.day"), + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.frequency.several.week"), + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.frequency.monthly"), + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.frequency.rarely"), + ), + "freeze_frequency", + ).useWrappingLabel(), + + CheckBoxGroupBlock( + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.label"), + listOf( + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.none"), "no_freezes"), + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.opening"), "opening_project"), + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.indexing"), "indexing"), + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.running"), "running_debugging"), + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.terminal"), "terminal"), + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.vcs"), "vcs"), + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.docker"), "docker"), + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.editing"), "editing"), + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.branches"), "switching_branches"), + CheckBoxItemData(WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.freeze.situations.search"), "search_navigation"), + ), + "freeze_situations", + ).setColumnCount(2).addOtherTextField(), + + TextAreaBlock( + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.dialog.additional.label", ideName), + "additional_feedback", + ), + ) + + init { + init() + } + + override fun createCenterPanel(): JComponent { + val centerPanel = super.createCenterPanel() + // The survey is long; cap the default height at 80% of the target screen (per the platform window-size + // guidelines) and let the content scroll, instead of growing the dialog to the full content height. + val maxHeight = (targetScreenBounds().height * 0.8).toInt() + val preferred = centerPanel.preferredSize + if (preferred.height > maxHeight) { + centerPanel.preferredSize = Dimension(preferred.width, maxHeight) + } + return centerPanel + } + + /** Bounds of the screen the dialog will appear on (it is centered over the parent project frame). */ + private fun targetScreenBounds(): Rectangle { + val frame = myProject?.let { WindowManager.getInstance().getFrame(it) } + return if (frame != null) ScreenUtil.getScreenRectangle(frame) else ScreenUtil.getMainScreenBounds() + } + + override fun showThanksNotification() { + ThanksForFeedbackNotification( + description = WslSatisfactionFeedbackBundle.message("wsl.satisfaction.thanks.feedback.content") + ).notify(myProject) + } +} diff --git a/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurvey.kt b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurvey.kt new file mode 100644 index 000000000000..7455bb7534f0 --- /dev/null +++ b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurvey.kt @@ -0,0 +1,12 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.feedback.wsl + +import com.intellij.platform.feedback.FeedbackSurvey +import com.intellij.platform.feedback.FeedbackSurveyType +import com.intellij.platform.feedback.InIdeFeedbackSurveyConfig +import com.intellij.platform.feedback.InIdeFeedbackSurveyType + +internal class WslSatisfactionSurvey : FeedbackSurvey() { + override val feedbackSurveyType: FeedbackSurveyType = + InIdeFeedbackSurveyType(WslSatisfactionSurveyConfig()) +} diff --git a/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyConfig.kt b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyConfig.kt new file mode 100644 index 000000000000..d1f57e938354 --- /dev/null +++ b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyConfig.kt @@ -0,0 +1,72 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.feedback.wsl + +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.InitialConfigImportState +import com.intellij.openapi.project.Project +import com.intellij.platform.feedback.InIdeFeedbackSurveyConfig +import com.intellij.platform.feedback.dialog.BlockBasedFeedbackDialog +import com.intellij.platform.feedback.dialog.SystemDataJsonSerializable +import com.intellij.platform.feedback.impl.notification.RequestFeedbackNotification +import com.intellij.util.PlatformUtils +import com.intellij.util.system.LowLevelLocalMachineAccess +import com.intellij.util.system.OS +import kotlinx.datetime.LocalDate + +internal class WslSatisfactionSurveyConfig : InIdeFeedbackSurveyConfig { + + override val surveyId: String = "wsl_satisfaction" + + // Survey is targeted at 2026.2 + 2026.2.x; collect for ~two months after release. Adjust to the actual release date. + override val lastDayOfFeedbackCollection: LocalDate = LocalDate(2026, 10, 1) + + override val requireIdeEAP: Boolean = false + + @OptIn(LowLevelLocalMachineAccess::class) + override fun checkIdeIsSuitable(): Boolean { + // WSL exists only on Windows; the brief limits the survey to these IDEs. + return OS.CURRENT == OS.Windows && + (PlatformUtils.isIdeaUltimate() || + PlatformUtils.isIdeaCommunity() || + PlatformUtils.isPyCharm() || + PlatformUtils.isWebStorm() || + PlatformUtils.isPhpStorm() || + PlatformUtils.isGoIde()) + } + + override fun checkExtraConditionSatisfied(project: Project): Boolean { + if (!isTargetVersion()) return false + // Don't show for new users - only for existing users who upgraded to the new version. + if (InitialConfigImportState.isNewUser()) return false + if (!project.isWslProject()) return false + return WslSatisfactionSurveyStore.getInstance().shouldShowDialog() + } + + private fun isTargetVersion(): Boolean { + return ApplicationInfo.getInstance().shortVersion == TARGET_VERSION + } + + override fun createFeedbackDialog(project: Project, forTest: Boolean): BlockBasedFeedbackDialog { + return WslSatisfactionFeedbackDialog(project, forTest) + } + + override fun updateStateAfterDialogClosedOk(project: Project) { + WslSatisfactionSurveyStore.getInstance().recordSurveyShown() + } + + override fun createNotification(project: Project, forTest: Boolean): RequestFeedbackNotification { + return RequestFeedbackNotification( + "Feedback In IDE", + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.notification.request.title"), + WslSatisfactionFeedbackBundle.message("wsl.satisfaction.notification.request.content"), + ) + } + + override fun updateStateAfterNotificationShowed(project: Project) { + // do nothing + } + + companion object { + private const val TARGET_VERSION: String = "2026.2" + } +} diff --git a/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyProjectActivity.kt b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyProjectActivity.kt new file mode 100644 index 000000000000..e24a5b2f6da3 --- /dev/null +++ b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyProjectActivity.kt @@ -0,0 +1,16 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.feedback.wsl + +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity + +/** + * Records that a WSL project was opened, feeding the "days working with WSL" counter used by + * [WslSatisfactionSurveyConfig] to decide when to show the WSL satisfaction survey. + */ +internal class WslSatisfactionSurveyProjectActivity : ProjectActivity { + override suspend fun execute(project: Project) { + if (!project.isWslProject()) return + WslSatisfactionSurveyStore.getInstance().recordWslProjectOpened() + } +} diff --git a/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyStore.kt b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyStore.kt new file mode 100644 index 000000000000..472feec7080c --- /dev/null +++ b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSurveyStore.kt @@ -0,0 +1,106 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.feedback.wsl + +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.util.registry.Registry +import com.intellij.util.system.LowLevelLocalMachineAccess +import com.intellij.util.system.OS +import java.time.Duration +import java.time.Instant + +private val WSL_PATH_PREFIXES = listOf("//wsl$/", "//wsl.localhost/") + +/** + * Whether [this] project is opened from a WSL file system. + * + * Uses the same lightweight base-path prefix check as + * [com.intellij.featureStatistics.fusCollectors.WslUsagesCollector], so it can be called + * on any thread without touching the (potentially slow) distribution manager. + */ +@OptIn(LowLevelLocalMachineAccess::class) +internal fun Project.isWslProject(): Boolean { + if (OS.CURRENT != OS.Windows) return false + val projectPath = guessProjectDir()?.path ?: return false + return WSL_PATH_PREFIXES.any { projectPath.startsWith(it) } +} + +internal class WslSatisfactionSurveyState : BaseState() { + // Unix time seconds of the first detected WSL project open. + var firstWslUseTime: Long by property(0L) + + // Number of distinct days on which a WSL project was open. + var daysWithWslProject: Int by property(0) + + // Unix time seconds; [daysWithWslProject] is incremented at most once after this moment. + var nextCountedDay: Long by property(0L) + var userSawSurvey: Boolean by property(false) +} + +@State(name = "WslSatisfactionSurveyStore", storages = [Storage(value = "wsl-survey.xml", roamingType = RoamingType.DISABLED)]) +internal class WslSatisfactionSurveyStore : PersistentStateComponent { + + internal var currentState: WslSatisfactionSurveyState = WslSatisfactionSurveyState() + + override fun getState(): WslSatisfactionSurveyState = currentState + + override fun loadState(state: WslSatisfactionSurveyState) { + currentState = state + } + + internal fun recordSurveyShown() { + currentState.userSawSurvey = true + } + + /** + * Records that a WSL project was open now. Increments [WslSatisfactionSurveyState.daysWithWslProject] + * at most once per calendar day and stamps the first-usage time. + */ + internal fun recordWslProjectOpened() { + val now = Instant.now() + if (now >= Instant.ofEpochSecond(currentState.nextCountedDay)) { + currentState.daysWithWslProject++ + currentState.nextCountedDay = (now + Duration.ofDays(1)).epochSecond + } + if (currentState.firstWslUseTime == 0L) { + currentState.firstWslUseTime = now.epochSecond + } + } + + internal fun shouldShowDialog(): Boolean { + val state = currentState + if (state.userSawSurvey) return false + + val daysWithWslProject = Registry.intValue(REGISTRY_DAYS_WITH_WSL, -1).takeIf { it >= 0 } ?: state.daysWithWslProject + if (daysWithWslProject < MINIMUM_DAYS_WITH_WSL_PROJECT) return false + + val firstWslUseTime = firstWslUseTime() ?: return false + return Duration.between(firstWslUseTime, Instant.now()) >= MINIMUM_DURATION_SINCE_FIRST_USE + } + + private fun firstWslUseTime(): Instant? { + val override = Registry.stringValue(REGISTRY_FIRST_USE_DATE) + if (override.isNotBlank()) { + return runCatching { java.time.LocalDate.parse(override).atStartOfDay(java.time.ZoneOffset.UTC).toInstant() }.getOrNull() + } + if (currentState.firstWslUseTime == 0L) return null + return Instant.ofEpochSecond(currentState.firstWslUseTime) + } + + companion object { + // "Project opened in WSL x3" + "after 3-5 days working with WSL" (IJPL-246739 brief). + internal const val MINIMUM_DAYS_WITH_WSL_PROJECT: Int = 3 + internal val MINIMUM_DURATION_SINCE_FIRST_USE: Duration = Duration.ofDays(3) + + internal const val REGISTRY_FIRST_USE_DATE: String = "wsl.survey.first.use.date" + internal const val REGISTRY_DAYS_WITH_WSL: String = "wsl.survey.days.with.wsl" + + fun getInstance(): WslSatisfactionSurveyStore = service() + } +} diff --git a/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSystemData.kt b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSystemData.kt new file mode 100644 index 000000000000..0002b61e6f57 --- /dev/null +++ b/platform/feedback/src/com/intellij/platform/feedback/wsl/WslSatisfactionSystemData.kt @@ -0,0 +1,25 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.feedback.wsl + +import com.intellij.openapi.util.NlsSafe +import com.intellij.platform.feedback.dialog.CommonFeedbackSystemData +import com.intellij.platform.feedback.dialog.SystemDataJsonSerializable +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Data attached automatically to the WSL satisfaction survey feedback, in addition to the answers: + * IDE name, WSL distribution and version, and whether WSL commands run via the IJent/EEL API. + */ +@Serializable +internal data class WslSatisfactionSystemData( + @NlsSafe val ideName: String, + @NlsSafe val wslDistribution: String, + val wslVersion: Int, + val isEelApiUsed: Boolean, + val commonData: CommonFeedbackSystemData, +) : SystemDataJsonSerializable { + override fun serializeToJson(json: Json): JsonElement = json.encodeToJsonElement(this) +}