mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-10-06 06:21:16 +07:00
toolbox-updates: IDEA-264161 - integrate with Notification actions UI + tests
GitOrigin-RevId: decebea1255f76d5c2bea45b97506564868a9d8a
This commit is contained in:
committed by
intellij-monorepo-bot
parent
314f02a866
commit
d58ed02bca
@@ -37,6 +37,9 @@
|
||||
|
||||
<httpRequestHandler implementation="org.jetbrains.ide.ToolboxRestService"/>
|
||||
<toolboxServiceHandler implementation="org.jetbrains.ide.ToolboxUpdateNotificationHandler"/>
|
||||
<toolboxServiceHandler implementation="org.jetbrains.ide.ToolboxRestartNotificationHandler"/>
|
||||
|
||||
<settingsEntryPointActionProvider implementation="org.jetbrains.ide.ToolboxSettingsActionRegistryActionProvider"/>
|
||||
|
||||
<httpRequestHandler implementation="org.jetbrains.builtInWebServer.liveReload.WebServerPageConnectionService$WebServerPageRequestHandler"/>
|
||||
<registryKey description="Enables automatic reloading of a web page loaded from built-in web server when a related file is changed and saved."
|
||||
|
||||
@@ -45,7 +45,8 @@ interface ToolboxServiceHandler<T> {
|
||||
*
|
||||
* Use the [onResult] to pass a result back to Toolbox and
|
||||
* to close the connection. The [lifetime] is disposed after
|
||||
* the connection is closed too
|
||||
* the connection is closed too, it must not be used after [onResult]
|
||||
* callback is executed
|
||||
*/
|
||||
fun handleToolboxRequest(
|
||||
lifetime: Disposable,
|
||||
@@ -160,17 +161,23 @@ internal class ToolboxRestService : RestService() {
|
||||
}
|
||||
.thenAcceptAsync(
|
||||
{ json ->
|
||||
try {
|
||||
//kill the heartbeat, it may close the lifetime too
|
||||
runCatching {
|
||||
heartbeat.cancel(false)
|
||||
heartbeat.await()
|
||||
channel.write(Unpooled.copiedBuffer(gson.toJson(json), Charsets.UTF_8))
|
||||
}
|
||||
finally {
|
||||
runCatching {
|
||||
channel.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
|
||||
}
|
||||
Disposer.dispose(lifetime)
|
||||
|
||||
//no need to do anything if it's already disposed
|
||||
if (Disposer.isDisposed(lifetime)) {
|
||||
//closing the channel just in case
|
||||
runCatching { channel.close() }
|
||||
return@thenAcceptAsync
|
||||
}
|
||||
|
||||
runCatching { channel.write(Unpooled.copiedBuffer(gson.toJson(json), Charsets.UTF_8)) }
|
||||
runCatching { channel.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) }
|
||||
|
||||
Disposer.dispose(lifetime)
|
||||
},
|
||||
AppExecutorUtil.getAppExecutorService()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
|
||||
package org.jetbrains.ide
|
||||
|
||||
import com.intellij.ide.actions.SettingsEntryPointAction
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.application.invokeLater
|
||||
import com.intellij.openapi.components.*
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.util.Alarm
|
||||
import com.intellij.util.ui.update.MergingUpdateQueue
|
||||
import com.intellij.util.ui.update.Update
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
internal class ToolboxSettingsActionRegistryState: BaseState() {
|
||||
val knownActions by list<String>()
|
||||
}
|
||||
|
||||
@Service(Service.Level.APP)
|
||||
@State(name = "toolbox-update-state", storages = [Storage(StoragePathMacros.CACHE_FILE)], allowLoadInTests = true)
|
||||
internal class ToolboxSettingsActionRegistry : SimplePersistentStateComponent<ToolboxSettingsActionRegistryState>(ToolboxSettingsActionRegistryState()), Disposable {
|
||||
private val pendingActions : MutableMap<String, AnAction> = ConcurrentHashMap()
|
||||
|
||||
private val alarm = MergingUpdateQueue("toolbox-updates", 500, true, null, this, null, Alarm.ThreadToUse.POOLED_THREAD).usePassThroughInUnitTestMode()
|
||||
|
||||
override fun dispose() = Unit
|
||||
|
||||
fun scheduleUpdate() {
|
||||
alarm.queue(object: Update(this){
|
||||
override fun run() {
|
||||
//we would not like it to overflow
|
||||
if (state.knownActions.size > 300) {
|
||||
val tail = state.knownActions.toList().takeLast(30).toHashSet()
|
||||
state.knownActions.clear()
|
||||
state.knownActions.addAll(tail)
|
||||
state.intIncrementModificationCount()
|
||||
}
|
||||
|
||||
val ids = pendingActions.keys.toSortedSet()
|
||||
val iconState = if (!state.knownActions.containsAll(ids)) {
|
||||
state.knownActions.addAll(ids)
|
||||
state.intIncrementModificationCount()
|
||||
|
||||
SettingsEntryPointAction.IconState.ApplicationUpdate
|
||||
} else {
|
||||
SettingsEntryPointAction.IconState.Current
|
||||
}
|
||||
|
||||
invokeLater {
|
||||
SettingsEntryPointAction.updateState(iconState)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun registerUpdateAction(lifetime: Disposable, persistentActionId: String, action: AnAction) {
|
||||
val dispose = Disposable {
|
||||
pendingActions.remove(persistentActionId, action)
|
||||
scheduleUpdate()
|
||||
}
|
||||
|
||||
pendingActions[persistentActionId] = action
|
||||
if (!Disposer.tryRegister(lifetime, dispose)) {
|
||||
Disposer.dispose(dispose)
|
||||
return
|
||||
}
|
||||
|
||||
scheduleUpdate()
|
||||
}
|
||||
|
||||
fun getActions() : List<AnAction> = pendingActions.entries.sortedBy { it.key }.map { it.value }
|
||||
}
|
||||
|
||||
class ToolboxSettingsActionRegistryActionProvider : SettingsEntryPointAction.ActionProvider {
|
||||
override fun getUpdateActions(context: DataContext) = service<ToolboxSettingsActionRegistry>().getActions()
|
||||
}
|
||||
+71
-15
@@ -3,26 +3,82 @@ package org.jetbrains.ide
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.intellij.ide.IdeBundle
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.invokeLater
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.DumbAwareAction
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.util.Consumer
|
||||
import com.intellij.util.concurrency.AppExecutorUtil
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal class ToolboxUpdateNotificationHandler : ToolboxServiceHandler<ToolboxUpdateNotificationHandler.UpdateNotification> {
|
||||
data class UpdateNotification(val version: String, val build: String)
|
||||
internal data class UpdateNotification(val version: String, val build: String)
|
||||
|
||||
private fun parseUpdateNotificationRequest(request: JsonElement): UpdateNotification {
|
||||
require(request.isJsonObject) { "JSON Object was expected" }
|
||||
val obj = request.asJsonObject
|
||||
|
||||
val build = obj["build"]?.asString
|
||||
val version = obj["version"]?.asString
|
||||
|
||||
require(!build.isNullOrBlank()) { "the `build` attribute must not be blank" }
|
||||
require(!version.isNullOrBlank()) { "the `version` attribute must not be blank" }
|
||||
return UpdateNotification(version = version, build = build)
|
||||
}
|
||||
|
||||
internal class ToolboxUpdateNotificationHandler : ToolboxServiceHandler<UpdateNotification> {
|
||||
override val requestName: String = "update-notification"
|
||||
|
||||
override fun parseRequest(request: JsonElement): UpdateNotification {
|
||||
require(request.isJsonObject) { "JSON Object was expected" }
|
||||
val obj = request.asJsonObject
|
||||
|
||||
val build = obj["build"]?.asString
|
||||
val version = obj["version"]?.asString
|
||||
|
||||
require(!build.isNullOrBlank()) { "the `build` attribute must not be blank" }
|
||||
require(!version.isNullOrBlank()) { "the `version` attribute must not be blank" }
|
||||
return UpdateNotification(version = version, build = build)
|
||||
}
|
||||
override fun parseRequest(request: JsonElement) = parseUpdateNotificationRequest(request)
|
||||
|
||||
override fun handleToolboxRequest(lifetime: Disposable, request: UpdateNotification, onResult: (JsonElement) -> Unit) {
|
||||
onResult(JsonObject().apply { addProperty("status", "not implemented") })
|
||||
val actionHandler = Consumer<AnActionEvent> {
|
||||
onResult(JsonObject().apply { addProperty("status", "accepted") })
|
||||
}
|
||||
|
||||
val title = IdeBundle.message("toolbox.updates.download.update.action.text", request.build, request.version)
|
||||
val description = IdeBundle.message("toolbox.updates.download.update.action.description", request.build, request.version)
|
||||
val action = DumbAwareAction.create(title, actionHandler)
|
||||
action.templatePresentation.description = description
|
||||
service<ToolboxSettingsActionRegistry>().registerUpdateAction(lifetime, "toolbox-02-update-${request.build}", action)
|
||||
}
|
||||
}
|
||||
|
||||
internal class ToolboxRestartNotificationHandler : ToolboxServiceHandler<UpdateNotification> {
|
||||
override val requestName: String = "restart-notification"
|
||||
override fun parseRequest(request: JsonElement) = parseUpdateNotificationRequest(request)
|
||||
|
||||
override fun handleToolboxRequest(lifetime: Disposable, request: UpdateNotification, onResult: (JsonElement) -> Unit) {
|
||||
val actionHandler = Consumer<AnActionEvent> {
|
||||
//at the normal scenario, the lifetime is disposed after the connection is closed
|
||||
//so Toolbox should get everything needed to handle the restart
|
||||
//otherwise an exception is thrown here, so it's OK
|
||||
Disposer.register(lifetime) {
|
||||
AppExecutorUtil.getAppScheduledExecutorService().schedule(Runnable {
|
||||
invokeLater {
|
||||
val app = ApplicationManager.getApplication()
|
||||
if (app?.isUnitTestMode == false) {
|
||||
app.exit(false, true, false)
|
||||
} else {
|
||||
System.setProperty("toolbox-service-test-restart", "1")
|
||||
}
|
||||
}
|
||||
}, 300, TimeUnit.MICROSECONDS)
|
||||
}
|
||||
|
||||
onResult(JsonObject().apply {
|
||||
addProperty("status", "accepted")
|
||||
addProperty("pid", ProcessHandle.current().pid())
|
||||
})
|
||||
}
|
||||
|
||||
val title = IdeBundle.message("toolbox.updates.download.ready.action.text", request.build, request.version)
|
||||
val description = IdeBundle.message("toolbox.updates.download.ready.action.description", request.build, request.version)
|
||||
val action = DumbAwareAction.create(title, actionHandler)
|
||||
action.templatePresentation.description = description
|
||||
|
||||
service<ToolboxSettingsActionRegistry>().registerUpdateAction(lifetime, "toolbox-02-update-${request.build}", action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
# run IDEA with -Dtoolbox.notification.token=token234
|
||||
|
||||
|
||||
POST 127.0.0.1:63343/api/toolbox/update-notification
|
||||
Authorization: toolbox token234
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"build": "2024.3.5 RC",
|
||||
"version": "2021.1 RC"
|
||||
}
|
||||
|
||||
|
||||
###
|
||||
|
||||
POST 127.0.0.1:63343/api/toolbox/restart-notification
|
||||
Authorization: toolbox token234
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"build": "2024.3.5 RC",
|
||||
"version": "2021.1 RC"
|
||||
}
|
||||
|
||||
@@ -4,13 +4,23 @@ package org.jetbrains.ide
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import com.intellij.ide.actions.SettingsEntryPointAction
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.impl.NonBlockingReadActionImpl
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.ApplicationRule
|
||||
import com.intellij.testFramework.ExtensionTestUtil
|
||||
import com.intellij.testFramework.LightPlatformTestCase
|
||||
import com.intellij.testFramework.TestApplicationManager
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import io.netty.handler.codec.http.HttpResponseStatus
|
||||
import org.assertj.core.api.Assertions
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.*
|
||||
import java.awt.event.KeyEvent
|
||||
import java.io.InputStream
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
@@ -20,16 +30,31 @@ import java.nio.charset.StandardCharsets
|
||||
import java.util.*
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.swing.JPanel
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
private abstract class ToolboxServiceHandlerX : ToolboxServiceHandler<JsonElement> {
|
||||
override val requestName: String = "update-notification"
|
||||
override fun parseRequest(request: JsonElement): JsonElement = request
|
||||
}
|
||||
|
||||
internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
|
||||
internal class ToolboxUpdateServiceTest {
|
||||
private val notifyUpdatePayload = "{'build': '123', 'version': '2021.1 RC'}"
|
||||
private val notifyUpdateAction = "update-notification"
|
||||
|
||||
private val notifyRestartPayload = "{'build': '123', 'version': '2021.1 RC'}"
|
||||
private val notifyRestartAction = "restart-notification"
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val appRule = ApplicationRule()
|
||||
|
||||
@Before
|
||||
fun runServer() {
|
||||
BuiltInServerManager.getInstance().waitForStart()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldCheckToken_noToken() {
|
||||
doToolbox(
|
||||
@@ -45,6 +70,89 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRestartAction() = doActionTest(notifyRestartAction, notifyRestartPayload) {
|
||||
Assert.assertEquals("$actions", 1, actions.size)
|
||||
val theAction = actions.single()
|
||||
|
||||
runAction(theAction)
|
||||
doEventsWhile(disconnected)
|
||||
|
||||
println("payload: $payload")
|
||||
|
||||
val json = JsonParser.parseString(payload)
|
||||
Assert.assertTrue(json.isJsonObject)
|
||||
Assert.assertEquals("accepted", json.asJsonObject["status"]?.asString)
|
||||
Assert.assertEquals(ProcessHandle.current().pid(), json.asJsonObject["pid"]?.asLong)
|
||||
|
||||
doEventsWhile(iterations = 10) { actions.isNotEmpty() }
|
||||
Assert.assertEquals("$actions", 0, actions.size)
|
||||
|
||||
val cond = { System.getProperty("toolbox-service-test-restart") == "1" }
|
||||
doEventsWhile(iterations = 10) { !cond() }
|
||||
Assert.assertTrue("Application must be disposed", cond())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateAction() = doActionTest(notifyUpdateAction, notifyRestartPayload) {
|
||||
Assert.assertEquals("$actions", 1, actions.size)
|
||||
val theAction = actions.single()
|
||||
|
||||
runAction(theAction)
|
||||
doEventsWhile(disconnected)
|
||||
println("payload: $payload")
|
||||
|
||||
val json = JsonParser.parseString(payload)
|
||||
Assert.assertTrue(json.isJsonObject)
|
||||
Assert.assertEquals("accepted", json.asJsonObject["status"]?.asString)
|
||||
|
||||
doEventsWhile(10) { actions.isNotEmpty() }
|
||||
Assert.assertEquals("$actions", 0, actions.size)
|
||||
}
|
||||
|
||||
private class ToolboxActionFixture {
|
||||
val connected = CountDownLatch(1)
|
||||
val disconnected = CountDownLatch(1)
|
||||
lateinit var payload: String
|
||||
|
||||
val actions
|
||||
get() = SettingsEntryPointAction.ActionProvider.EP_NAME
|
||||
.extensionList.filterIsInstance<ToolboxSettingsActionRegistryActionProvider>()
|
||||
.flatMap {
|
||||
it.getUpdateActions(DataContext.EMPTY_CONTEXT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doActionTest(requestName: String,
|
||||
requestPayload: String,
|
||||
logic: ToolboxActionFixture.() -> Unit) {
|
||||
val f = ToolboxActionFixture()
|
||||
val thread = thread {
|
||||
doToolbox(
|
||||
action = requestName, payload = requestPayload,
|
||||
includeToken = true, responseStatus = HttpResponseStatus.OK
|
||||
) {
|
||||
f.connected.countDown()
|
||||
try {
|
||||
f.payload = it.body().readAllBytes().toString(Charsets.UTF_8)
|
||||
}
|
||||
finally {
|
||||
f.disconnected.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
doEventsWhile(f.connected)
|
||||
doEventsWhile(10) { f.actions.isEmpty() }
|
||||
return f.logic()
|
||||
}
|
||||
finally {
|
||||
thread.interrupt()
|
||||
thread.join()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldCheckToken_withToken_beat_and_response() {
|
||||
val condition = CountDownLatch(10)
|
||||
@@ -61,7 +169,7 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
|
||||
|
||||
withExtension(ext) {
|
||||
doToolbox(
|
||||
action = notifyUpdateAction, payload = notifyUpdatePayload,
|
||||
action = ext.requestName,
|
||||
includeToken = true, responseStatus = HttpResponseStatus.OK,
|
||||
) { response ->
|
||||
val body = response.body()
|
||||
@@ -93,7 +201,7 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
|
||||
|
||||
withExtension(ext) {
|
||||
doToolbox(
|
||||
action = notifyUpdateAction, payload = notifyUpdatePayload,
|
||||
action = ext.requestName,
|
||||
includeToken = true, responseStatus = HttpResponseStatus.OK,
|
||||
) { response ->
|
||||
var counter = 10
|
||||
@@ -121,7 +229,7 @@ internal class ToolboxUpdateServiceTest : BuiltInServerTestCase() {
|
||||
}
|
||||
|
||||
private fun doToolbox(action: String,
|
||||
payload: String,
|
||||
payload: String = " { } ",
|
||||
includeToken: Boolean = true,
|
||||
responseStatus: HttpResponseStatus,
|
||||
body : (HttpResponse<InputStream>) -> Unit = {}
|
||||
@@ -165,3 +273,29 @@ private inline fun <Y> withSystemProperty(key: String, value: String, action: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun doEventsWhile(latch: CountDownLatch) = doEventsWhile {
|
||||
!latch.await(10, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
private fun doEventsWhile(iterations: Int = Int.MAX_VALUE / 2,
|
||||
condition: () -> Boolean = { true }) {
|
||||
repeat(iterations) {
|
||||
if (!condition()) return
|
||||
|
||||
ApplicationManager.getApplication().invokeAndWait {
|
||||
NonBlockingReadActionImpl.waitForAsyncTaskCompletion()
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
}
|
||||
|
||||
if (!condition()) return
|
||||
Thread.sleep(30)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runAction(theAction: AnAction) {
|
||||
ApplicationManager.getApplication().invokeAndWait {
|
||||
val event = KeyEvent(JPanel(), 1, 0, 0, 0, ' ')
|
||||
ActionManager.getInstance().tryToExecute(theAction, event, null, null, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2563,3 +2563,10 @@ trusted.hosts.settings.new.trusted.folder.file.chooser.title=Select Trusted Loca
|
||||
windows.native.common.dialog.open=Open
|
||||
windows.native.common.dialog.select.folder=Select Folder
|
||||
dialog.message.press.for.field.hints=Press {0} for field hints
|
||||
|
||||
toolbox.updates.download.update.action.text=Update to {0}...
|
||||
toolbox.updates.download.update.action.description=Toolbox App will download the update to {0} ({1}) and let you \
|
||||
know when the update is ready to switch
|
||||
|
||||
toolbox.updates.download.ready.action.text=Restart to {0}...
|
||||
toolbox.updates.download.ready.action.description=Toolbox App has prepared an update to {0} ({1}). Restart to the new version?
|
||||
|
||||
Reference in New Issue
Block a user