Skip to content

Commit 8ad57a6

Browse files
authored
Suggest enabling edit resources (#3149)
* Add ability to suggest experiments including handling notifications * Adding event emitting on experiment state change * Suggest enabling the edit experiment on dynamic resources * Fixing PR feedback * Removing nasty -1 (replacing with slightly less nasty Long.MAX_VALUE) * Removing unnecessary blank line * Adding MutableMap.replace equivalent (and tests) * Fixing serialization bug
1 parent 93bca3b commit 8ad57a6

File tree

7 files changed

+283
-12
lines changed

7 files changed

+283
-12
lines changed

core/src/software/aws/toolkits/core/utils/CollectionUtils.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ package software.aws.toolkits.core.utils
77
* Removes all items in this collection and replaces them with the items in the [other] collection
88
*/
99
fun <T> MutableCollection<T>.replace(other: Collection<T>) {
10-
this.clear()
11-
this.addAll(other)
10+
clear()
11+
addAll(other)
12+
}
13+
14+
/**
15+
* Removes all items in this map and replaces them with the items in the [other] map
16+
*/
17+
fun <K, V> MutableMap<K, V>.replace(other: Map<K, V>) {
18+
clear()
19+
putAll(other)
1220
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.core.utils
5+
6+
import org.assertj.core.api.Assertions.assertThat
7+
import org.junit.Test
8+
9+
class CollectionUtilsTest {
10+
11+
@Test
12+
fun `collection items are replaced`() {
13+
val source = mutableListOf("hello")
14+
15+
source.replace(listOf("world"))
16+
17+
assertThat(source).containsOnly("world")
18+
}
19+
20+
@Test
21+
fun `map entries are replaced`() {
22+
val source = mutableMapOf("foo" to "bar")
23+
source.replace(mapOf("hello" to "world"))
24+
25+
assertThat(source).containsOnlyKeys("hello")
26+
}
27+
}

jetbrains-core/resources/META-INF/plugin.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ with what features/services are supported.
9696
topic="software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsStateChangeNotifier"/>
9797
<listener class="software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceStateChangedNotificationHandler"
9898
topic="software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceStateMutationHandler"/>
99-
99+
<listener class="software.aws.toolkits.jetbrains.services.dynamic.SuggestEditExperimentListener"
100+
topic="com.intellij.openapi.fileEditor.FileEditorManagerListener"/>
101+
<listener class="software.aws.toolkits.jetbrains.services.dynamic.UpdateOnExperimentState"
102+
topic="software.aws.toolkits.jetbrains.core.experiments.ToolkitExperimentStateChangedListener"/>
100103
</projectListeners>
101104
<extensionPoints>
102105
<extensionPoint name="credentialProviderFactory" interface="software.aws.toolkits.core.credentials.CredentialProviderFactory" dynamic="true"/>

jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ToolkitExperiment.kt

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,27 @@
33

44
package software.aws.toolkits.jetbrains.core.experiments
55

6+
import com.intellij.openapi.actionSystem.AnActionEvent
7+
import com.intellij.openapi.application.ApplicationManager
68
import com.intellij.openapi.components.BaseState
79
import com.intellij.openapi.components.PersistentStateComponent
810
import com.intellij.openapi.components.State
911
import com.intellij.openapi.components.Storage
1012
import com.intellij.openapi.components.service
1113
import com.intellij.openapi.extensions.ExtensionPointName
14+
import com.intellij.openapi.project.DumbAwareAction
15+
import com.intellij.util.messages.Topic
1216
import com.intellij.util.xmlb.annotations.Property
17+
import software.aws.toolkits.core.utils.replace
1318
import software.aws.toolkits.jetbrains.AwsToolkit
19+
import software.aws.toolkits.jetbrains.utils.createNotificationExpiringAction
20+
import software.aws.toolkits.jetbrains.utils.notifyInfo
21+
import software.aws.toolkits.resources.message
1422
import software.aws.toolkits.telemetry.AwsTelemetry
1523
import software.aws.toolkits.telemetry.ExperimentState.Activated
1624
import software.aws.toolkits.telemetry.ExperimentState.Deactivated
25+
import java.time.Duration
26+
import java.time.Instant
1727

1828
/**
1929
* Used to control the state of an experimental feature.
@@ -22,6 +32,7 @@ import software.aws.toolkits.telemetry.ExperimentState.Deactivated
2232
*
2333
* @param hidden determines whether this experiment should surface in the settings/menus; hidden experiments can only be enabled by system property or manually modifying config in aws.xml
2434
* @param default determines the default state of an experiment
35+
* @param suggestionSnooze how long to wait between prompting a suggestion to enable the experiment (when using the experiment suggestion system ([ToolkitExperiment.suggest]))
2536
*
2637
* `ToolkitExperiment` implementations should be an `object` for example:
2738
*
@@ -42,7 +53,8 @@ abstract class ToolkitExperiment(
4253
internal val title: () -> String,
4354
internal val description: () -> String,
4455
internal val hidden: Boolean = false,
45-
internal val default: Boolean = false
56+
internal val default: Boolean = false,
57+
internal val suggestionSnooze: Duration = Duration.ofDays(7)
4658
) {
4759
override fun equals(other: Any?) = (other as? ToolkitExperiment)?.id?.equals(id) == true
4860
override fun hashCode() = id.hashCode()
@@ -51,13 +63,48 @@ abstract class ToolkitExperiment(
5163
fun ToolkitExperiment.isEnabled(): Boolean = ToolkitExperimentManager.getInstance().isEnabled(this)
5264
internal fun ToolkitExperiment.setState(enabled: Boolean) = ToolkitExperimentManager.getInstance().setState(this, enabled)
5365

66+
/**
67+
* Surface a notification suggesting that the given experiment be enabled.
68+
*/
69+
fun ToolkitExperiment.suggest() {
70+
if (ToolkitExperimentManager.getInstance().shouldPrompt(this)) {
71+
notifyInfo(
72+
title = message("aws.toolkit.experimental.suggestion.title"),
73+
content = message("aws.toolkit.experimental.suggestion.description", title(), description()),
74+
notificationActions = listOf(
75+
createNotificationExpiringAction(EnableExperiment(this)),
76+
createNotificationExpiringAction(NeverShowAgain(this))
77+
),
78+
stripHtml = false
79+
)
80+
}
81+
}
82+
83+
private class EnableExperiment(private val experiment: ToolkitExperiment) :
84+
DumbAwareAction(message("aws.toolkit.experimental.enable")) {
85+
override fun actionPerformed(e: AnActionEvent) {
86+
experiment.setState(true)
87+
}
88+
}
89+
90+
private class NeverShowAgain(private val experiment: ToolkitExperiment) : DumbAwareAction(message("settings.never_show_again")) {
91+
override fun actionPerformed(e: AnActionEvent) {
92+
ToolkitExperimentManager.getInstance().neverPrompt(experiment)
93+
}
94+
}
95+
5496
@State(name = "experiments", storages = [Storage("aws.xml")])
5597
internal class ToolkitExperimentManager : PersistentStateComponent<ExperimentState> {
56-
private val enabledState = mutableMapOf<String, Boolean>()
98+
private val state = ExperimentState()
99+
private val enabledState get() = state.value
100+
57101
fun isEnabled(experiment: ToolkitExperiment): Boolean =
58102
EP_NAME.extensionList.contains(experiment) && enabledState.getOrDefault(experiment.id, getDefault(experiment))
59103

60104
fun setState(experiment: ToolkitExperiment, enabled: Boolean) {
105+
if (enabled != isEnabled(experiment)) {
106+
ApplicationManager.getApplication().messageBus.syncPublisher(EXPERIMENT_CHANGED).enableSettingsStateChanged(experiment)
107+
}
61108
if (enabled == getDefault(experiment)) {
62109
enabledState.remove(experiment.id)
63110
} else {
@@ -73,11 +120,11 @@ internal class ToolkitExperimentManager : PersistentStateComponent<ExperimentSta
73120
)
74121
}
75122

76-
override fun getState(): ExperimentState = ExperimentState().apply { value.putAll(enabledState) }
123+
override fun getState(): ExperimentState = state
77124

78-
override fun loadState(state: ExperimentState) {
79-
enabledState.clear()
80-
enabledState.putAll(state.value)
125+
override fun loadState(loadedState: ExperimentState) {
126+
state.value.replace(loadedState.value)
127+
state.nextSuggestion.replace(loadedState.nextSuggestion)
81128
}
82129

83130
private fun getDefault(experiment: ToolkitExperiment): Boolean {
@@ -89,14 +136,40 @@ internal class ToolkitExperimentManager : PersistentStateComponent<ExperimentSta
89136
}
90137
}
91138

139+
internal fun shouldPrompt(experiment: ToolkitExperiment, now: Instant = Instant.now()): Boolean {
140+
if (experiment.isEnabled()) {
141+
return false
142+
}
143+
val should = state.nextSuggestion[experiment.id]?.let { now.isAfter(Instant.ofEpochMilli(it)) } ?: true
144+
if (should) {
145+
state.nextSuggestion[experiment.id] = now.plus(experiment.suggestionSnooze).toEpochMilli()
146+
}
147+
return should
148+
}
149+
150+
internal fun neverPrompt(experiment: ToolkitExperiment) {
151+
state.nextSuggestion[experiment.id] = Long.MAX_VALUE // This is ~240 years in the future, effectively "never".
152+
}
153+
92154
companion object {
93155
internal val EP_NAME = ExtensionPointName.create<ToolkitExperiment>("aws.toolkit.experiment")
156+
internal val EXPERIMENT_CHANGED = Topic.create("experiment service enable state changed", ToolkitExperimentStateChangedListener::class.java)
94157
internal fun getInstance(): ToolkitExperimentManager = service()
95158
internal fun visibleExperiments(): List<ToolkitExperiment> = EP_NAME.extensionList.filterNot { it.hidden }
96159
}
97160
}
98161

99162
internal class ExperimentState : BaseState() {
163+
// This represents whether an experiment is enabled or not, don't want to rename it as that will
164+
// cause problems with any experiments already out there in the wild who've been persisted
165+
// as 'value'
100166
@get:Property
101167
val value by map<String, Boolean>()
168+
169+
@get:Property
170+
val nextSuggestion by map<String, Long>()
171+
}
172+
173+
interface ToolkitExperimentStateChangedListener {
174+
fun enableSettingsStateChanged(toolkitExperiment: ToolkitExperiment)
102175
}

jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/JsonResourceModificationExperiment.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,36 @@
33

44
package software.aws.toolkits.jetbrains.services.dynamic
55

6+
import com.intellij.openapi.fileEditor.FileEditorManager
7+
import com.intellij.openapi.fileEditor.FileEditorManagerListener
8+
import com.intellij.openapi.project.Project
9+
import com.intellij.openapi.vfs.VirtualFile
10+
import com.intellij.ui.EditorNotifications
611
import software.aws.toolkits.jetbrains.core.experiments.ToolkitExperiment
12+
import software.aws.toolkits.jetbrains.core.experiments.ToolkitExperimentStateChangedListener
13+
import software.aws.toolkits.jetbrains.core.experiments.suggest
714
import software.aws.toolkits.resources.message
815

916
object JsonResourceModificationExperiment : ToolkitExperiment(
1017
"jsonResourceModification",
1118
{ message("dynamic_resources.experiment.title") },
1219
{ message("dynamic_resources.experiment.description") }
1320
)
21+
22+
class SuggestEditExperimentListener : FileEditorManagerListener {
23+
override fun fileOpened(source: FileEditorManager, file: VirtualFile) {
24+
if (file is DynamicResourceVirtualFile) {
25+
JsonResourceModificationExperiment.suggest()
26+
}
27+
}
28+
}
29+
30+
class UpdateOnExperimentState(private val project: Project) : ToolkitExperimentStateChangedListener {
31+
override fun enableSettingsStateChanged(toolkitExperiment: ToolkitExperiment) {
32+
if (toolkitExperiment is JsonResourceModificationExperiment) {
33+
with(EditorNotifications.getInstance(project)) {
34+
FileEditorManager.getInstance(project).openFiles.filterIsInstance<DynamicResourceVirtualFile>().forEach { updateNotifications(it) }
35+
}
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)