diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e32aec..5b1602a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,7 +49,7 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 with: gradle-home-cache-cleanup: true @@ -113,7 +113,7 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 with: gradle-home-cache-cleanup: true @@ -131,7 +131,7 @@ jobs: # Upload the Kover report to CodeCov - name: Upload Code Coverage Report - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: files: ${{ github.workspace }}/build/reports/kover/report.xml @@ -166,7 +166,7 @@ jobs: # Run Qodana inspections - name: Qodana - Code Inspection - uses: JetBrains/qodana-action@v2023.3.0 + uses: JetBrains/qodana-action@v2023.3.1 with: cache-default-branch-only: true @@ -197,13 +197,13 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 with: gradle-home-cache-cleanup: true # Cache Plugin Verifier IDEs - name: Setup Plugin Verifier IDEs Cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ needs.build.outputs.pluginVerifierHomeDir }}/ides key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d48e56..ef10f57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/run-ui-tests.yml b/.github/workflows/run-ui-tests.yml index 05e483b..aedb91d 100644 --- a/.github/workflows/run-ui-tests.yml +++ b/.github/workflows/run-ui-tests.yml @@ -44,7 +44,7 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + uses: gradle/gradle-build-action@v3 with: gradle-home-cache-cleanup: true @@ -54,7 +54,7 @@ jobs: # Wait for IDEA to be started - name: Health Check - uses: jtalk/url-health-check-action@v3 + uses: jtalk/url-health-check-action@v4 with: url: http://127.0.0.1:8082 max-attempts: 15 diff --git a/.gitignore b/.gitignore index e2e5d94..080816f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ .idea .qodana build + +.notes +.run \ No newline at end of file diff --git a/.run/Run Plugin.run.xml b/.run/Run Plugin.run.xml index d15ff68..6df3995 100644 --- a/.run/Run Plugin.run.xml +++ b/.run/Run Plugin.run.xml @@ -19,6 +19,7 @@ true true false + false \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac3bb2..4c1734f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,7 @@ ## [Unreleased] ### Added -- Initial scaffold created from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template) +- Embedded Wokwi simulator +- Wokwi console to control simulator +- wokwi.toml analysis support +- Wokwi simulation debugging support diff --git a/README.md b/README.md index b6c816d..53958cd 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,119 @@ -# IntelliJ Wokwi Simulator Plugin -This plugin integrates the [Wokwi](https://wokwi.com/) simulator for ESP32 devices into JetBrains' IntelliJ-based IDEs. -The project is currently in its early stages and has not yet been published on JetBrains' Marketplace. + -## Project Status + +[![Contributors][contributors-shield]][contributors-url] +[![MIT License][license-shield]][license-url] +[![Issues][issues-shield]][issues-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] -The version in the `main` branch is very unstable and uses a non-maintaned version of the Wokwi simulator. Therefore, the plugin is rewritten and uses the same Wokwi simulator as the official VS-Code extension. In addition, the plugin configuration is aligned with the VS-Code configuration, i.e. the file `wokwi.toml` defines all relevant settings. This makes switching between IDEs effortless. + +
+
+ + + Wokwi Intellij Icon + + -The progress of the new plugin version is tracked in the pull request [#14](https://github.com/Jozott00/wokwi-intellij/pull/14). +

Wokwi Intellij Plugin

+

+ Integrate Wokwi in Intellij-based Jetbrains IDEs. +
+ Explore the docs » +
+
+ Report Bug + · + Request Feature +

+
+ +## About The Plugin -## Main branch version +![Wokwi Debug Showcase](blob/imgs/sim_running.png) -At present, it is possible to specify and run a binary on the Wokwi simulator within the IDE. By enabling binary watch, -the simulation automatically restarts after each new binary build. + +The Wokwi Intellij plugin, an open-source tool, integrates the [Wokwi](https://wokwi.com) simulator with Jetbrains IDEs like CLion and RustRover. +It adopts the Wokwi VS Code extension's configuration approach for seamless IDE transitions, supporting the same platforms. + +This plugin is a community plugin and not maintained by the [Wokwi](https://wokwi.com) team. + + +### Features +- Run simulation in IDE window +- Automatically restart the simulation on rebuild +- Intelligent configuration checking +- Intellij idiomatic debugging (CLion only) + + +## Documentation + +Please visit the [Wokwi Intellij documentation](https://jozott00.github.io/wokwi-intellij/starter-topic.html). + +### Installation + +To follow the installation instructions, users typically navigate to the [installation section](https://jozott00.github.io/wokwi-intellij/starter-topic.html#installation). + +For building and installing the plugin from source: +1. Clone or download the repository. +2. Execute `./gradlew buildPlugin`.
This action saves the plugin build as `build/distributions/wokwi-intellij-x.x.x.zip`. +3. Follow steps to [install the plugin from disk](https://www.jetbrains.com/help/idea/managing-plugins.html#install_plugin_from_disk). + + + + +## Roadmap + +- [ ] Make Console writable +- [ ] Add Serial Port forwarding +- [ ] Add IoT gateway +- [ ] Support custom chips +- [ ] Add diagram.json editor + +See the [open issues](https://github.com/Jozott00/wokwi-intellij/issues) for a full list of proposed features (and known issues). + + + +## Contributing + +To make this plugin even better, contributions are very welcome! + +If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag `enhancement`. +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + + + + +## License + +Distributed under the MIT License. See `LICENSE.txt` for more information. + + + + + + +[contributors-shield]: https://img.shields.io/github/contributors/jozott00/wokwi-intellij.svg +[contributors-url]: https://github.com/Jozott00/wokwi-intellij/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/jozott00/wokwi-intellij.svg +[forks-url]: https://github.com/Jozott00/wokwi-intellij/network/members +[stars-shield]: https://img.shields.io/github/stars/jozott00/wokwi-intellij.svg +[stars-url]: https://github.com/Jozott00/wokwi-intellij/stargazers +[issues-shield]: https://img.shields.io/github/issues/jozott00/wokwi-intellij.svg +[issues-url]: https://github.com/Jozott00/wokwi-intellij/issues +[license-shield]: https://img.shields.io/github/license/Jozott00/wokwi-intellij.svg +[license-url]: https://github.com/Jozott00/wokwi-intellij/blob/master/LICENSE.txt -![Simulation Configuration](https://github.com/Jozott00/wokwi-intellij/blob/main/blob/imgs/sim_screenshot0.png) -![Running Simulation](https://github.com/Jozott00/wokwi-intellij/blob/main/blob/imgs/sim_screenshot1.png) diff --git a/blob/imgs/logoColorful.svg b/blob/imgs/logoColorful.svg new file mode 100644 index 0000000..9f76a06 --- /dev/null +++ b/blob/imgs/logoColorful.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/blob/imgs/pluginIcon.svg b/blob/imgs/pluginIcon.svg new file mode 100644 index 0000000..7f3b453 --- /dev/null +++ b/blob/imgs/pluginIcon.svg @@ -0,0 +1,45 @@ + + + + + + + + diff --git a/blob/imgs/pluginIcon_dark.svg b/blob/imgs/pluginIcon_dark.svg new file mode 100644 index 0000000..f1ca467 --- /dev/null +++ b/blob/imgs/pluginIcon_dark.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/blob/imgs/sim_cfg.png b/blob/imgs/sim_cfg.png new file mode 100644 index 0000000..73e4666 Binary files /dev/null and b/blob/imgs/sim_cfg.png differ diff --git a/blob/imgs/sim_dbg.png b/blob/imgs/sim_dbg.png new file mode 100644 index 0000000..6a4c04f Binary files /dev/null and b/blob/imgs/sim_dbg.png differ diff --git a/blob/imgs/sim_running.png b/blob/imgs/sim_running.png new file mode 100644 index 0000000..3588d55 Binary files /dev/null and b/blob/imgs/sim_running.png differ diff --git a/build.gradle.kts b/build.gradle.kts index 481bad5..aaaca0d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,7 +27,11 @@ repositories { dependencies { implementation(files("libs/espimg-0.1.0.jar")) implementation("org.java-websocket:Java-WebSocket:1.5.4") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") + implementation("com.beust:klaxon:5.6") + implementation("com.akuleshov7:ktoml-core:0.5.1") + implementation("com.akuleshov7:ktoml-file:0.5.1") + implementation("io.arrow-kt:arrow-core:1.2.1") } // Set the JVM language level used to build the project. Use Java 11 for 2020.3+, and Java 17 for 2022.2+. @@ -148,7 +152,6 @@ tasks { // The pluginVersion is based on the SemVer (https://semver.org) and supports pre-release labels, like 2.1.7-alpha.3 // Specify pre-release label to publish the plugin in a custom Release Channel automatically. Read more: // https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel - channels = - properties("pluginVersion").map { listOf(it.split('-').getOrElse(1) { "default" }.split('.').first()) } + channels = properties("pluginVersion").map { listOf(it.split('-').getOrElse(1) { "default" }.split('.').first()) } } } diff --git a/docs/devnotes/c.list b/docs/devnotes/c.list new file mode 100644 index 0000000..c4c77a2 --- /dev/null +++ b/docs/devnotes/c.list @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/docs/devnotes/redirection-rules.xml b/docs/devnotes/redirection-rules.xml new file mode 100644 index 0000000..abcd171 --- /dev/null +++ b/docs/devnotes/redirection-rules.xml @@ -0,0 +1,13 @@ + + + + + + Created after removal of "Debug Architecture" from wokwi-intellij-notes + Debug-Architecture.html + + \ No newline at end of file diff --git a/docs/devnotes/topics/Communication-Architecture.md b/docs/devnotes/topics/Communication-Architecture.md new file mode 100644 index 0000000..12420ed --- /dev/null +++ b/docs/devnotes/topics/Communication-Architecture.md @@ -0,0 +1,24 @@ +# Communication Architecture + +The plugin uses a wrapper around the simulator webview (iframe), to establish communication with the plugin. +It essentially serves as communcation proxy between the plugin and webview, as the communication +requirements for both sides are different. + +> **Wcode** is the VSCode version of the Wokwi Simulator + +```mermaid +sequenceDiagram + Intellij Plugin -->> Wcode Wrapper: Injects MessageRouter to listen on window.jcef() + Wcode -->> Wcode Wrapper: Sends message using postMessage(..., port) + + Wcode Wrapper --> Wcode: Communicates over exchanged port + Intellij Plugin --> Wcode Wrapper: Communicates over injected message router + + Intellij Plugin --> Wcode: Communicates using Wcode Wrapper in between +``` + +## Wcode + +The url of the Wcode simulator is `https://wokwi.com/vscode/wcode?v=` + +E.g. `https://wokwi.com/vscode/wcode?v=2.4.0&g=10277ff&u=385442252248670209` diff --git a/docs/devnotes/topics/Debugger.md b/docs/devnotes/topics/Debugger.md new file mode 100644 index 0000000..8c3b64d --- /dev/null +++ b/docs/devnotes/topics/Debugger.md @@ -0,0 +1,11 @@ +# Debugger + +> This is not yet a good note +{style="warning"} + + + Research on other debuggers + Create custom runner + + +See [Custom Intellij Debugger](Intellij-Debugger.md) for research results. diff --git a/docs/devnotes/topics/Intellij-Debugger.md b/docs/devnotes/topics/Intellij-Debugger.md new file mode 100644 index 0000000..bc8fea7 --- /dev/null +++ b/docs/devnotes/topics/Intellij-Debugger.md @@ -0,0 +1,17 @@ +# Intellij Debugger + +Research results regarding the Intellij platform debuggers. + +**Intellij Community Classes** +- [XDebugProcess Abstract Class](https://github.com/JetBrains/intellij-community/blob/master/platform/xdebugger-api/src/com/intellij/xdebugger/XDebugProcess.java#L37) + provides debugging capabilities for a custom language/framework + + +## Python Intellij Debugger +[Source Directory](https://github.com/JetBrains/intellij-community/tree/master/python/src/com/jetbrains/python/debugger) + +The [PyRemoteDebugProcess](https://github.com/JetBrains/intellij-community/blob/master/python/src/com/jetbrains/python/debugger/PyRemoteDebugProcess.java) +and its [PyDebugProcess](https://github.com/JetBrains/intellij-community/blob/master/python/src/com/jetbrains/python/debugger/PyDebugProcess.java) might be +a good starting point to implement a debug process to attach to Wokwi's GDB stub. + +For the Runner take a look at the [Execution Documentation](https://plugins.jetbrains.com/docs/intellij/execution.html) \ No newline at end of file diff --git a/docs/devnotes/topics/Wokwi-Code-Notes.md b/docs/devnotes/topics/Wokwi-Code-Notes.md new file mode 100644 index 0000000..cb83608 --- /dev/null +++ b/docs/devnotes/topics/Wokwi-Code-Notes.md @@ -0,0 +1,127 @@ +# Wokwi Code Notes + +## Firmware Packaging + +Reads all required flash files and combines them in a `[[{offset: _, data: _}}]]` array at the right offset. +This is only used for **ESP-IDF** projects (C++ only), with a `build/flasher_args.json`. + +```Javascript +a.packageEspIdfFirmware = async function (flasherArgsJson, firmwareBasePath) { + const flasherArgs = JSON.parse(flasherArgsJson.toString()); + + if (!flasherArgs.flash_files) { + throw new Error("flash_files key is missing in flasher_args.json"); + } + + let fileContents = []; + let filePaths = []; + let maxFirmwareSize = 0; + + for (const [offsetHex, filePath] of Object.entries(flasherArgs.flash_files)) { + const offset = parseInt(offsetHex, 16); + if (isNaN(offset)) { + throw new Error(`Invalid offset in flasher_args.json: ${offsetHex}`); + } + + const resolvedFilePath = path.resolve(firmwareBasePath, filePath); + filePaths.push(resolvedFilePath); + + const fileData = await readFileOrNull(resolvedFilePath); + if (!fileData) { + throw new Error(`Could not read file: ${filePath}`); + } + + fileContents.push({offset, data: fileData}); + maxFirmwareSize = Math.max(maxFirmwareSize, offset + fileData.byteLength); + } + + if (maxFirmwareSize > 16777216) { + throw new Error(`Firmware size (${maxFirmwareSize} bytes) exceeds maximum supported size (16777216 bytes)`); + } + + const firmwareData = new Uint8Array(maxFirmwareSize); + for (const {offset, data} of fileContents) { + firmwareData.set(new Uint8Array(data), offset); + } + + return { + firmware: firmwareData, + watchPaths: filePaths + }; +}; + +``` + +{collapsible="true"} + +```javascript +// Listener for the 'start' event with an asynchronous callback function +t.listen("start", async settings => { + // Destructuring the settings object for easier access to its properties + let { + diagram: diagramJSON, + firmware: firmwareData, + chips: chipSettings, + useGateway: shouldUseGateway, + pause: shouldPause, + firmwareB64: isFirmwareBase64, + disableSerialMonitor: shouldDisableSerialMonitor + } = settings; + + try { + // Attempt to process the license and initialize some component (represented by L()) + x(await k(settings.license, L())) + } catch (error) { + // Handle any errors during the process + j(true) + } + + // Check if the firmware data is not in the expected format + if (typeof firmwareData === "object" && !(firmwareData instanceof ArrayBuffer)) { + // Send a command to switch to Base64 if the condition is met + t.write({ + command: "switchToBase64" + }); + return; + } + + // Processing chip settings if available + for (let chip of chipSettings || []) { + e.addFile(`${chip.name}.chip.json`, JSON.stringify(chip.json), true); + } + + // Adding diagram and configuring firmware data + e.addFile("diagram.json", diagramJSON, true), + r.overrideHex = isFirmwareBase64 ? (0, A.Xs)(firmwareData).buffer : firmwareData, + r.wifiGateway = shouldUseGateway ? i : undefined, + e.setChips(chipSettings), + + // Handling chip output + r.onChipOutput = (chipName, message) => { + t.write({ + command: "chipOutput", + chipName: chipName, + message: message + }) + }, + + // Setting up the current state + q.current = { + diagram: e.diagram, + files: [], + chips: chipSettings, + autoPause: shouldPause, + hideSerialMonitor: shouldDisableSerialMonitor + }, + + // Starting the process and handling completion + r.start(q.current).then(() => { + let wifiStatus; + g(false), + wifiStatus = r.wifi?.status; + wifiStatus?.setGatewayType("vscode") + }) +}); +``` + +{collapsible="true"} \ No newline at end of file diff --git a/docs/devnotes/topics/Wokwi-Config.md b/docs/devnotes/topics/Wokwi-Config.md new file mode 100644 index 0000000..3bc4a4b --- /dev/null +++ b/docs/devnotes/topics/Wokwi-Config.md @@ -0,0 +1,7 @@ +# Wokwi Config + +We use the TOML intellij plugin as foundation layer of Wokwi configs. + +> Take a look at the [Rust-Intellij extension of TOML](https://github.com/intellij-rust/intellij-rust/pull/1982/files) +> And +> the [current implementation](https://github.com/intellij-rust/intellij-rust/tree/master/src/main/kotlin/org/rust/toml) \ No newline at end of file diff --git a/docs/devnotes/topics/starter-topic.md b/docs/devnotes/topics/starter-topic.md new file mode 100644 index 0000000..5427892 --- /dev/null +++ b/docs/devnotes/topics/starter-topic.md @@ -0,0 +1,4 @@ +# Overview + +This is the developer notes documentation to archive the Wokwi infrastructure, code examples, related documentation and more. + diff --git a/docs/devnotes/v.list b/docs/devnotes/v.list new file mode 100644 index 0000000..2d12cb3 --- /dev/null +++ b/docs/devnotes/v.list @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/devnotes/wokwi-intellij.tree b/docs/devnotes/wokwi-intellij.tree new file mode 100644 index 0000000..24c6f12 --- /dev/null +++ b/docs/devnotes/wokwi-intellij.tree @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/devnotes/writerside.cfg b/docs/devnotes/writerside.cfg new file mode 100644 index 0000000..2d778c3 --- /dev/null +++ b/docs/devnotes/writerside.cfg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 49a8a62..7a17c63 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,34 +1,25 @@ # IntelliJ Platform Artifacts Repositories -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html - -pluginGroup = com.github.jozott00.wokwiintellij -pluginName = wokwi-intellij -pluginRepositoryUrl = https://github.com/Jozott00/wokwi-intellij +pluginGroup=com.github.jozott00.wokwiintellij +pluginName=wokwi-intellij +pluginRepositoryUrl=https://github.com/Jozott00/wokwi-intellij # SemVer format -> https://semver.org -pluginVersion = 0.0.1 - +pluginVersion=0.9.0 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html -pluginSinceBuild = 223 -pluginUntilBuild = 233.* - +pluginSinceBuild=232 +pluginUntilBuild=233.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension -platformType = IC -platformVersion = 2023.3.2 - +platformType=IC +platformVersion=2023.3 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 -platformPlugins = - +platformPlugins=org.toml.lang # Gradle Releases -> https://github.com/gradle/gradle/releases -gradleVersion = 8.5 - +gradleVersion=8.5 # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib -kotlin.stdlib.default.dependency = false - +kotlin.stdlib.default.dependency=false # Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html -org.gradle.configuration-cache = true - +org.gradle.configuration-cache=true # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html -org.gradle.caching = true - +org.gradle.caching=true # Enable Gradle Kotlin DSL Lazy Property Assignment -> https://docs.gradle.org/current/userguide/kotlin_dsl.html#kotdsl:assignment -systemProp.org.gradle.unsafe.kotlin.assignment = true +systemProp.org.gradle.unsafe.kotlin.assignment=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f6b4ec3..773bb14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ annotations = "24.1.0" # plugins kotlin = "1.9.22" changelog = "2.2.0" -gradleIntelliJPlugin = "1.16.1" +gradleIntelliJPlugin = "1.17.0" qodana = "0.1.13" kover = "0.7.5" diff --git a/settings.gradle.kts b/settings.gradle.kts index d597ebc..e9cf604 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,6 @@ plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.7.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } rootProject.name = "wokwi-intellij" diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/MyBundle.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/WokwiBundle.kt similarity index 83% rename from src/main/kotlin/com/github/jozott00/wokwiintellij/MyBundle.kt rename to src/main/kotlin/com/github/jozott00/wokwiintellij/WokwiBundle.kt index 273c49d..7277ed4 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/MyBundle.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/WokwiBundle.kt @@ -5,9 +5,9 @@ import org.jetbrains.annotations.NonNls import org.jetbrains.annotations.PropertyKey @NonNls -private const val BUNDLE = "messages.MyBundle" +private const val BUNDLE = "messages.WokwiBundle" -object MyBundle : DynamicBundle(BUNDLE) { +object WokwiBundle : DynamicBundle(BUNDLE) { @JvmStatic fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any) = diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/WokwiConstants.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/WokwiConstants.kt new file mode 100644 index 0000000..a14ae06 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/WokwiConstants.kt @@ -0,0 +1,12 @@ +package com.github.jozott00.wokwiintellij + +object WokwiConstants { + const val WOWKI_PLUGIN_SERVICE_NAME = "WokwiIntellij" + const val TOOL_WINDOW_SIM_ID = "Wokwi Simulator" + const val WOKWI_CONFIG_FILE = "wokwi.toml" + const val WOKWI_DIAGRAM_FILE = "diagram.json" + const val WOKWI_DEFAULT_CONFIG_VERSION = "1" + const val WOKWI_LICENCE_STORE_KEY = "WokwiLicense" + const val WOKWI_WCODE_VERSION = "1.0" + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiRestartAction.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiRestartAction.kt index be49a63..1349448 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiRestartAction.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiRestartAction.kt @@ -3,12 +3,11 @@ package com.github.jozott00.wokwiintellij.actions import com.github.jozott00.wokwiintellij.services.WokwiProjectService import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.ToggleAction import com.intellij.openapi.components.service class WokwiRestartAction : AnAction() { override fun actionPerformed(p0: AnActionEvent) { - p0.project?.service()?.restartSimulation() + p0.project?.service()?.startSimulator() } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiStartAction.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiStartAction.kt index 4e33042..e39abee 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiStartAction.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiStartAction.kt @@ -1,15 +1,20 @@ package com.github.jozott00.wokwiintellij.actions -import com.github.jozott00.wokwiintellij.services.WokwiComponentService import com.github.jozott00.wokwiintellij.services.WokwiProjectService -import com.github.jozott00.wokwiintellij.services.WokwiSimulationService import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service class WokwiStartAction : AnAction() { + + + // TODO: Consider run configuration instead of custom run handling override fun actionPerformed(event: AnActionEvent) { +// event.project?.let { +// val config = RunManager.getInstance(it).createConfiguration("Wokwi Runner", WokwiConfigurationFactory(WokwiRunConfigType())) +// ProgramRunnerUtil.executeConfiguration(config, DefaultRunExecutor.getRunExecutorInstance()) +// } val s = event.project?.service() s?.startSimulator() } diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiStopAction.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiStopAction.kt index 4cf7d7f..9ddbcfc 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiStopAction.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiStopAction.kt @@ -1,15 +1,26 @@ package com.github.jozott00.wokwiintellij.actions -import com.github.jozott00.wokwiintellij.services.WokwiComponentService import com.github.jozott00.wokwiintellij.services.WokwiProjectService -import com.github.jozott00.wokwiintellij.services.WokwiSimulationService +import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service -import com.intellij.openapi.ui.Messages class WokwiStopAction : AnAction() { + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + + override fun update(e: AnActionEvent) { + val s = e.project?.service() ?: return + val p = e.presentation + + p.isEnabled = s.isSimulatorRunning() + + } + override fun actionPerformed(event: AnActionEvent) { val s = event.project?.service() s?.stopSimulator() diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiWatchAction.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiWatchAction.kt index 46fca37..a3c4a1a 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiWatchAction.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/actions/WokwiWatchAction.kt @@ -1,24 +1,23 @@ package com.github.jozott00.wokwiintellij.actions -import com.github.jozott00.wokwiintellij.services.WokwiProjectService -import com.github.jozott00.wokwiintellij.states.WokwiConfigState +import com.github.jozott00.wokwiintellij.states.WokwiSettingsState +import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.ToggleAction import com.intellij.openapi.components.service class WokwiWatchAction : ToggleAction() { + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + override fun isSelected(p0: AnActionEvent): Boolean { - return p0.project?.service()?.state?.watchElf ?: false + return p0.project?.service()?.state?.watchFirmware ?: false } override fun setSelected(even: AnActionEvent, watchEnabled: Boolean) { - even.project?.service()?.state?.watchElf = watchEnabled - if (watchEnabled) { - even.project?.service()?.watchStart() - } else { - even.project?.service()?.watchStop() - } + even.project?.service()?.state?.watchFirmware = watchEnabled } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/exceptions/results.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/exceptions/results.kt new file mode 100644 index 0000000..8c28e3d --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/exceptions/results.kt @@ -0,0 +1,4 @@ +package com.github.jozott00.wokwiintellij.exceptions + +open class WokwiError +data class GenericError(val title: String, val message: String): WokwiError() \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/exceptions/utils.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/exceptions/utils.kt new file mode 100644 index 0000000..797e766 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/exceptions/utils.kt @@ -0,0 +1,10 @@ +package com.github.jozott00.wokwiintellij.exceptions + + +inline fun catchIllArg(block: () -> R): Result { + return try { + Result.success(block()) + } catch (e: IllegalArgumentException) { + Result.failure(e) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/extensions/DisposableExt.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/extensions/DisposableExt.kt new file mode 100644 index 0000000..c897a44 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/extensions/DisposableExt.kt @@ -0,0 +1,11 @@ +package com.github.jozott00.wokwiintellij.extensions + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.util.Disposer + +fun Disposable.disposeByDisposer() { + invokeLater { + Disposer.dispose(this) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/extensions/projectExt.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/extensions/projectExt.kt new file mode 100644 index 0000000..2d27543 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/extensions/projectExt.kt @@ -0,0 +1,62 @@ +package com.github.jozott00.wokwiintellij.extensions + +import com.github.jozott00.wokwiintellij.services.WokwiPluginDisposable +import com.github.jozott00.wokwiintellij.services.WokwiProjectService +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.vfs.VirtualFile +import kotlinx.coroutines.CoroutineScope +import java.net.URI +import java.nio.file.Path +import kotlin.io.path.exists + + +/** + * Represents a disposable object for the Wokwi plugin in a project. + * + * This property returns an instance of the `WokwiPluginDisposable` class, which is a service + * intended to be used as a parent disposable instead of the project itself. + */ +val Project.wokwiDisposable get() = service() as Disposable + +/** + * Creates a new CoroutineScope for the given childName in scope of the WokwiProjectService. + * + * @param childName the name of the child scope. + * @return the created CoroutineScope for the specified childName. + */ +@Suppress("unused") +fun Project.wokwiCoroutineChildScope(childName: String): CoroutineScope { + return service().childScope(childName) +} + +/** + * Finds the relative paths of files or directories within the project. + * + * @param path the path to resolve against project's content roots + * @return a list of resolved relative paths + */ +@Suppress("unused") +fun Project.findRelativePaths(path: String): List { + val rootUrls = ProjectRootManager.getInstance(this).contentRootUrls + return rootUrls.map { + Path.of(URI(it)).resolve(path) + }.filter { + it.exists() + } +} + +/** + * Finds the relative files based on the given path. + * + * @param path The relative path of the files to be found. + * @return A list of virtual files matching the given relative path. If no files are found, returns an empty list. + */ +fun Project.findRelativeFiles(path: String): List { + val rootUrls = ProjectRootManager.getInstance(this).contentRoots + return rootUrls.mapNotNull { + it.findFileByRelativePath(path) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/extensions/stringExt.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/extensions/stringExt.kt new file mode 100644 index 0000000..df5d418 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/extensions/stringExt.kt @@ -0,0 +1,7 @@ +package com.github.jozott00.wokwiintellij.extensions + +fun String.hexStringToByteArray(): ByteArray? = this.removePrefix("0x") + .chunked(2) + .map { it.toIntOrNull(16) ?: return null } + .map { it.toByte() } + .toByteArray() diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/WokwiFileType.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/WokwiFileType.kt new file mode 100644 index 0000000..ad27a24 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/WokwiFileType.kt @@ -0,0 +1,23 @@ +package com.github.jozott00.wokwiintellij.ide + +import com.github.jozott00.wokwiintellij.ui.WokwiIcons +import com.intellij.openapi.fileTypes.LanguageFileType +import com.intellij.openapi.fileTypes.ex.FileTypeIdentifiableByVirtualFile +import com.intellij.openapi.vfs.VirtualFile +import org.toml.lang.TomlLanguage +import org.toml.lang.psi.TomlFileType + +object WokwiFileType : LanguageFileType(TomlLanguage), FileTypeIdentifiableByVirtualFile { + + override fun getName() = "WOKWI_TOML" + + override fun getDescription() = "Wokwi configuration" + + override fun getDefaultExtension() = "toml" + + override fun getIcon() = WokwiIcons.ConfigFile + + override fun isMyFileType(file: VirtualFile): Boolean { + return file.nameWithoutExtension == "wokwi" && file.extension == TomlFileType.defaultExtension + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/ConfigVersionInspection.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/ConfigVersionInspection.kt new file mode 100644 index 0000000..b676c8f --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/ConfigVersionInspection.kt @@ -0,0 +1,49 @@ +package com.github.jozott00.wokwiintellij.ide.inspections + +import com.github.jozott00.wokwiintellij.WokwiBundle +import com.github.jozott00.wokwiintellij.WokwiConstants +import com.intellij.codeInspection.LocalQuickFix +import com.intellij.codeInspection.ProblemDescriptor +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElementVisitor +import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.TomlPsiFactory + +class ConfigVersionInspection : WokwiConfigInspectionBase() { + + override fun buildVisitorInternal(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { + return object : WokwiConfigVisitor() { + override fun visitVersionValue(value: TomlKeyValue) { + super.visitVersionValue(value) + + if (value.value?.text != "1") { + holder.registerProblem( + value, + WokwiBundle.message("config.inspection.version.invalid.problem.descriptor"), + SetValidVersionQuickFix + ) + } + } + } + } + + + object SetValidVersionQuickFix : LocalQuickFix { + override fun getFamilyName(): String { + return WokwiBundle.message( + "config.inspection.version.invalid.quickfix.change", + WokwiConstants.WOKWI_DEFAULT_CONFIG_VERSION + ) + } + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + val elem = descriptor.psiElement as TomlKeyValue + + val newElem = TomlPsiFactory(project, true) + .createKeyValue("version", WokwiConstants.WOKWI_DEFAULT_CONFIG_VERSION) + elem.replace(newElem) + } + + } +} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/ElfFirmwareInspection.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/ElfFirmwareInspection.kt new file mode 100644 index 0000000..f73068e --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/ElfFirmwareInspection.kt @@ -0,0 +1,49 @@ +package com.github.jozott00.wokwiintellij.ide.inspections + +import com.github.jozott00.wokwiintellij.WokwiBundle +import com.github.jozott00.wokwiintellij.toml.stringValue +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.psi.PsiElementVisitor +import org.toml.lang.psi.TomlKeyValue + +class ElfFirmwareInspection : WokwiConfigInspectionBase() { + + override fun buildVisitorInternal(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { + return object : WokwiConfigVisitor() { + override fun visitElfValue(value: TomlKeyValue) { + super.visitElfValue(value) + inspectBinaryPath(value) + } + + override fun visitFirmwareValue(value: TomlKeyValue) { + super.visitFirmwareValue(value) + inspectBinaryPath(value) + } + + private fun inspectBinaryPath(value: TomlKeyValue) { + val path = value.value?.stringValue ?: run { + holder.registerProblem( + value, + WokwiBundle.message("config.inspection.binary.invalid.string.descriptor") + ) + return + } + + val configRootDir = holder.file.virtualFile.parent + val filePath = configRootDir.toNioPath().resolve(path) + val file = LocalFileSystem.getInstance().findFileByNioFile(filePath) + if (file == null || file.isDirectory) { + holder.registerProblem( + value, + WokwiBundle.message("config.inspection.binary.invalid.path.descriptor", file?.path.toString()) + ) + return + } + } + + } + } + + +} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/MissingConfigurationInspection.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/MissingConfigurationInspection.kt new file mode 100644 index 0000000..11a1867 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/MissingConfigurationInspection.kt @@ -0,0 +1,179 @@ +package com.github.jozott00.wokwiintellij.ide.inspections + +import com.github.jozott00.wokwiintellij.WokwiBundle +import com.github.jozott00.wokwiintellij.WokwiConstants +import com.github.jozott00.wokwiintellij.toml.findTable +import com.github.jozott00.wokwiintellij.toml.findValue +import com.intellij.codeInsight.template.TemplateManager +import com.intellij.codeInsight.template.impl.TextExpression +import com.intellij.codeInspection.* +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import org.toml.lang.psi.TomlFile +import org.toml.lang.psi.TomlPsiFactory +import org.toml.lang.psi.TomlTable +import org.toml.lang.psi.TomlTableHeader + +private fun InspectionManager.createErrorDescription( + elem: PsiElement, + descriptor: String, + quickFix: LocalQuickFix?, + type: ProblemHighlightType = ProblemHighlightType.ERROR +): ProblemDescriptor { + return createProblemDescriptor(elem, descriptor, quickFix, type, false) +} + +class MissingConfigurationInspection : WokwiConfigInspectionBase() { + + override fun checkFileInternal(file: PsiFile, manager: InspectionManager, isOnTheFly: Boolean): Array { + val tomlFile = file as TomlFile + + val wokwiTable = tomlFile.findTable("wokwi") ?: run { + return arrayOf( + manager.createErrorDescription( + tomlFile, + WokwiBundle.message("config.inspection.missing.wokwi.problem.descriptor"), + AddWokwiConfiguration + ) + ) + } + + val problems = mutableListOf() + + if (wokwiTable.findValue("version") == null) { + problems.add( + manager.createErrorDescription( + wokwiTable.header, + WokwiBundle.message("config.inspection.missing.version.problem.descriptor"), + AddWokwiAttribute( + "version", + WokwiConstants.WOKWI_DEFAULT_CONFIG_VERSION, + false, + WokwiBundle.message("config.inspection.missing.version.quickfix") + ) + ) + ) + } + + if (wokwiTable.findValue("elf") == null) { + problems.add( + manager.createErrorDescription( + wokwiTable.header, + WokwiBundle.message("config.inspection.missing.elf.problem.descriptor"), + AddWokwiAttribute( + "elf", + "path/to/your/elf", + true, + WokwiBundle.message("config.inspection.missing.elf.quickfix") + ) + ) + ) + } + + if (wokwiTable.findValue("firmware") == null) { + problems.add( + manager.createErrorDescription( + wokwiTable.header, + WokwiBundle.message("config.inspection.missing.firmware.problem.descriptor"), + AddWokwiAttribute( + "firmware", + "path/to/your/firmware", + true, + WokwiBundle.message("config.inspection.missing.firmware.quickfix"), + ) + ) + ) + } + + + + return problems.toTypedArray() + } + + object AddWokwiConfiguration : LocalQuickFix { + override fun getFamilyName(): String { + return WokwiBundle.message("config.inspection.missing.wokwi.quickfix") + } + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + val file = descriptor.psiElement as TomlFile + val factory = TomlPsiFactory(project, true) + val table = factory.createTable("wokwi") + table.add(factory.createNewline()) + + val version = factory.createKeyValue("version", WokwiConstants.WOKWI_DEFAULT_CONFIG_VERSION) + table.add(version) + + file.add(table) + + } + + } + + class AddWokwiAttribute( + private val attribute: String, + private val defaultValue: String, + private val runTemplate: Boolean, + private val familyName: String, + ) : LocalQuickFix { + + override fun getFamilyName(): String { + return familyName + } + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + if (!ApplicationManager.getApplication().isDispatchThread) + return + + val factory = TomlPsiFactory(project, true) + + val wokwiTable = when (val elem = descriptor.psiElement) { + is TomlTable -> elem + is TomlTableHeader -> elem.parent as TomlTable + else -> { + thisLogger().error("Invalid PSI element for AddWokwiAttribute quickfix: $elem") + return + } + } + + wokwiTable.add(factory.createNewline()) + if (!runTemplate) { + // if we do not want a template we just add the attribute + val elem = factory.createKeyValue(attribute, defaultValue) + wokwiTable.add(elem) + return + } + + + val editor = + FileEditorManager.getInstance(project).selectedTextEditor ?: return + PsiDocumentManager.getInstance(project) + .doPostponedOperationsAndUnblockDocument(editor.document) + insertTemplate(wokwiTable, project, editor) + } + + private fun insertTemplate(wokwiTable: TomlTable, project: Project, editor: Editor) { + val templateManager = TemplateManager.getInstance(project) + val template = templateManager.createTemplate("", "", "$attribute = \"\$PATH$\"").apply { + addVariable("PATH", TextExpression(defaultValue), true) + } + + // Insert the template at end of wokwi table + val caretModel = editor.caretModel + val offset = wokwiTable.textRange.endOffset + caretModel.moveToOffset(offset) + + templateManager.startTemplate(editor, template) + } + + + } + + +} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/WokwiConfigInspectionBase.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/WokwiConfigInspectionBase.kt new file mode 100644 index 0000000..74c74b2 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/WokwiConfigInspectionBase.kt @@ -0,0 +1,31 @@ +package com.github.jozott00.wokwiintellij.ide.inspections + +import com.github.jozott00.wokwiintellij.toml.isWokwiToml +import com.intellij.codeInspection.InspectionManager +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.ProblemDescriptor +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.PsiElementVisitor +import com.intellij.psi.PsiFile + +abstract class WokwiConfigInspectionBase: LocalInspectionTool() { + + final override fun checkFile( + file: PsiFile, + manager: InspectionManager, + isOnTheFly: Boolean + ): Array? { + if (!file.isWokwiToml) return super.checkFile(file, manager, isOnTheFly) + return checkFileInternal(file, manager, isOnTheFly) + } + + protected open fun checkFileInternal(file: PsiFile, manager: InspectionManager, isOnTheFly: Boolean): Array? = super.checkFile(file, manager, isOnTheFly) + + final override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor { + if (!holder.file.isWokwiToml) return super.buildVisitor(holder, isOnTheFly) + return buildVisitorInternal(holder, isOnTheFly) + } + + protected open fun buildVisitorInternal(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor = super.buildVisitor(holder, isOnTheFly) + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/WokwiConfigVisitor.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/WokwiConfigVisitor.kt new file mode 100644 index 0000000..e7aac1e --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ide/inspections/WokwiConfigVisitor.kt @@ -0,0 +1,56 @@ +package com.github.jozott00.wokwiintellij.ide.inspections + +import com.github.jozott00.wokwiintellij.toml.stringValue +import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.TomlTable +import org.toml.lang.psi.TomlVisitor + +@Suppress("EmptyMethod", "EmptyMethod") +open class WokwiConfigVisitor : TomlVisitor() { + + open fun visitWokwiTable(value: TomlTable) { + for (e in value.entries) { + visitWokwiKeyValue(e) + } + } + + open fun visitVersionValue(value: TomlKeyValue) { + + } + + open fun visitElfValue(value: TomlKeyValue) { + + } + + open fun visitFirmwareValue(value: TomlKeyValue) { + + } + + open fun visitUnknownTable(ignoredValue: TomlTable) { + + } + + open fun visitUnknownWokwiValue() { + + } + + private fun visitWokwiKeyValue(element: TomlKeyValue) { + when (element.key.stringValue) { + "version" -> visitVersionValue(element) + "elf" -> visitElfValue(element) + "firmware" -> visitFirmwareValue(element) + else -> visitUnknownWokwiValue() + } + } + + override fun visitTable(element: TomlTable) { + super.visitTable(element) + + if (element.header.key?.stringValue == "wokwi") { + return visitWokwiTable(element) + } + visitUnknownTable(element) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/jcef/BrowserPipe.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/jcef/BrowserPipe.kt new file mode 100644 index 0000000..9a52b86 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/jcef/BrowserPipe.kt @@ -0,0 +1,32 @@ +package com.github.jozott00.wokwiintellij.jcef + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer + +/** + * The `BrowserPipe` interface represents a pipe for communication between a browser and its subscribers. + * It provides methods for sending messages, subscribing to receive messages, and removing subscribers. + * + * This interface extends the `Disposable` interface, which means it can be disposed to release any resources + * it may be holding. + */ +interface BrowserPipe : Disposable { + + fun send(type: String, data: String) + + + fun subscribe(type: String, subscriber: Subscriber) + + fun subscribe(type: String, subscriber: Subscriber, parent: Disposable) { + Disposer.register(parent) { removeSubscriber(type, subscriber) } + subscribe(type, subscriber) + } + + + fun removeSubscriber(type: String, subscriber: Subscriber) + + interface Subscriber { + fun messageReceived(data: String): Boolean + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/jcef/JcefUtil.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/jcef/JcefUtil.kt new file mode 100644 index 0000000..a616502 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/jcef/JcefUtil.kt @@ -0,0 +1,37 @@ +package com.github.jozott00.wokwiintellij.jcef + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefClient +import org.cef.browser.CefBrowser +import org.cef.handler.CefLoadHandler +import org.cef.handler.CefRequestHandler +import org.intellij.lang.annotations.Language + +internal fun JBCefBrowser.executeJavaScript(@Language("JavaScript") code: String) { + cefBrowser.executeJavaScript(code, null, 0) +} + +@Suppress("unused") +internal fun JBCefClient.addRequestHandler( + handler: CefRequestHandler, + browser: CefBrowser, + parentDisposable: Disposable +) { + Disposer.register(parentDisposable) { removeRequestHandler(handler, browser) } + addRequestHandler(handler, browser) +} + +internal fun JBCefClient.addLoadHandler( + handler: CefLoadHandler, + browser: CefBrowser, + parentDisposable: Disposable +) { + Disposer.register(parentDisposable) { removeLoadHandler(handler, browser) } + addLoadHandler(handler, browser) +} + +internal fun JBCefBrowser.addLoadHandler(handler: CefLoadHandler, parentDisposable: Disposable) { + jbCefClient.addLoadHandler(handler, cefBrowser, parentDisposable) +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/jcef/impl/JcefBrowserPipe.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/jcef/impl/JcefBrowserPipe.kt new file mode 100644 index 0000000..12b801f --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/jcef/impl/JcefBrowserPipe.kt @@ -0,0 +1,128 @@ +package com.github.jozott00.wokwiintellij.jcef.impl + +import com.github.jozott00.wokwiintellij.jcef.BrowserPipe +import com.github.jozott00.wokwiintellij.jcef.addLoadHandler +import com.github.jozott00.wokwiintellij.jcef.executeJavaScript +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.util.Disposer +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.handler.CefLoadHandlerAdapter +import org.intellij.lang.annotations.Language + +class JcefBrowserPipe(private val browser: JBCefBrowser, parentDisposable: Disposable) : BrowserPipe, CefLoadHandlerAdapter() { + + // subscribers to specific types + private val subscribers = hashMapOf>() + + private val injectQuery = JBCefJSQuery.create(browser as JBCefBrowserBase) + + init { + Disposer.register(parentDisposable, this) + Disposer.register(this, injectQuery) + injectQuery.addHandler(::onReceive) + browser.addLoadHandler(this, this) + } + + override fun send(type: String, data: String) { + val funCall = """ + window.$namspaceInBrowser.$receiveMessageFromIntelliFunc("$type", $data); + """.trimIndent() + + browser.executeJavaScript(funCall) + } + + override fun subscribe(type: String, subscriber: BrowserPipe.Subscriber) { + subscribers.merge(type, mutableListOf(subscriber)) { current, _ -> + current.also { it.add(subscriber) } + } + } + + override fun removeSubscriber(type: String, subscriber: BrowserPipe.Subscriber) { + subscribers[type]?.remove(subscriber) + if (subscribers[type]?.isEmpty() == true) { + subscribers.remove(type) + } + } + + override fun dispose() { + subscribers.clear() + + } + + // inject code to browser + override fun onLoadEnd(browser: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) { + @Language("JavaScript") + val code = """ + window.$namspaceInBrowser.$postMessageToIntellijFunc = data => ${injectQuery.inject("data")}; + """.trimIndent() + + browser?.executeJavaScript(code, null, 0) + browser?.executeJavaScript("window.dispatchEvent(new Event('IdeReady'));", null, 0) + } + + @Suppress("SameReturnValue") + private fun onReceive(msg: String): JBCefJSQuery.Response? { + val (type, data) = msg.let(::parseObj) ?: return null + informSubscribers(type, data) + return null + } + + private fun informSubscribers(type: String, data: String) { + when (val subs = subscribers[type]) { + null -> logger.warn("No subscribers for $type!\nAttached data: $data") + else -> subs.takeWhile { it.messageReceived(data) } + } + } + + private fun parseObj(json: String): MessageObj? { + try { + return Json.decodeFromString(json) + } catch (e: Exception) { + logger.error(e) + return null + } + } + + companion object { + val logger = logger() + + const val namspaceInBrowser = "__WokwiIntellij" + const val postMessageToIntellijFunc = "__postMessageToPipe" + const val receiveMessageFromIntelliFunc = "__receiveMessageFromPipe" + + + } + + + @Serializable + private data class MessageObj( + val type: String, + @Serializable(with = RawJsonSerializer::class) val data: String + ) + + @OptIn(ExperimentalSerializationApi::class) + @Serializer(forClass = String::class) + private object RawJsonSerializer : KSerializer { + override fun serialize(encoder: Encoder, value: String) { + encoder.encodeString(value) + } + + override fun deserialize(decoder: Decoder): String { + return decoder.decodeSerializableValue(JsonElement.serializer()).toString() + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiActivationListener.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiActivationListener.kt deleted file mode 100644 index 77ff3b5..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiActivationListener.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.jozott00.wokwiintellij.listeners - -import com.intellij.openapi.application.ApplicationActivationListener -import com.intellij.openapi.diagnostic.thisLogger -import com.intellij.openapi.wm.IdeFrame - -internal class WokwiActivationListener : ApplicationActivationListener { - - override fun applicationActivated(ideFrame: IdeFrame) { - thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.") - } -} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiElfFileListener.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiFirmwareWatcher.kt similarity index 51% rename from src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiElfFileListener.kt rename to src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiFirmwareWatcher.kt index 9fdbd0b..f1a403a 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiElfFileListener.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiFirmwareWatcher.kt @@ -1,38 +1,42 @@ package com.github.jozott00.wokwiintellij.listeners import com.github.jozott00.wokwiintellij.services.WokwiProjectService -import com.github.jozott00.wokwiintellij.states.WokwiConfigState -import com.intellij.openapi.components.Service +import com.github.jozott00.wokwiintellij.states.WokwiSettingsState import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.newvfs.BulkFileListener import com.intellij.openapi.vfs.newvfs.events.VFileEvent -class WokwiElfFileListener(val project: Project) : BulkFileListener { - +class WokwiFirmwareWatcher(val project: Project) : BulkFileListener { override fun after(events: MutableList) { - val configState = project.service() + val configState = project.service() + val projectService = project.service() - if (!configState.watchElf) return + if (!configState.watchFirmware) return + val watchPaths = projectService.getWatchPaths() ?: return - val watchPath = configState.elfPath val result = events.find { if (it.file?.isInLocalFileSystem != true) return@find false - if (it.file?.path == watchPath) + if (watchPaths.contains(it.file?.path)) return@find true false } - val projectService = project.service() if (result != null) { - projectService.elfFileUpdate() + LOG.info("Triggered with: ${events.map { it.path }}") + LOG.info("Watch against: $watchPaths") + projectService.firmwareUpdated() } } + companion object { + private val LOG = logger() + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiPostStartupActivity.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiPostStartupActivity.kt deleted file mode 100644 index 4f0cc6c..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiPostStartupActivity.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.jozott00.wokwiintellij.listeners - -import com.github.jozott00.wokwiintellij.services.WokwiProjectService -import com.intellij.openapi.components.service -import com.intellij.openapi.project.Project -import com.intellij.openapi.startup.ProjectActivity -import com.intellij.openapi.startup.StartupActivity - -class WokwiPostStartupActivity : ProjectActivity { - override suspend fun execute(project: Project) { - val projectService = project.service() - projectService.startup() - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiProjectManagerListener.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiProjectManagerListener.kt deleted file mode 100644 index e49020a..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/listeners/WokwiProjectManagerListener.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.jozott00.wokwiintellij.listeners - -import com.intellij.openapi.components.Service -import com.intellij.openapi.diagnostic.thisLogger -import com.intellij.openapi.project.Project -import com.github.jozott00.wokwiintellij.MyBundle -import com.intellij.openapi.project.ProjectManagerListener -import com.intellij.openapi.project.impl.ProjectLifecycleListener - - - diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/WokwiConfigurationFactory.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/WokwiConfigurationFactory.kt new file mode 100644 index 0000000..2462cce --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/WokwiConfigurationFactory.kt @@ -0,0 +1,27 @@ +package com.github.jozott00.wokwiintellij.runner + +import com.github.jozott00.wokwiintellij.runner.configs.* +import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.execution.configurations.ConfigurationType +import com.intellij.openapi.project.Project + +class WokwiConfigurationFactory(type: ConfigurationType) : ConfigurationFactory(type) { + override fun getId(): String { + return type.id + } + + override fun createTemplateConfiguration( + project: Project + ) = when (type) { + is WokwiStartDebugConfigType -> WokwiStartDebugConfig(project, this, type.displayName) + is WokwiRunConfigType -> WokwiRunConfig(project, this, type.displayName) + else -> error("Invalid configuration type") + } + + + override fun getOptionsClass() = when (type) { + is WokwiStartDebugConfigType -> WokwiStartDebugConfigOptions::class.java + is WokwiRunConfigType -> WokwiRunConfigOptions::class.java + else -> error("Invalid configuration type") + } +} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/WokwiProcessHandler.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/WokwiProcessHandler.kt new file mode 100644 index 0000000..1f3311a --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/WokwiProcessHandler.kt @@ -0,0 +1,7 @@ +package com.github.jozott00.wokwiintellij.runner + +import com.github.jozott00.wokwiintellij.simulator.WokwiSimulatorListener +import com.intellij.execution.process.ProcessHandler + +abstract class WokwiProcessHandler : ProcessHandler(), WokwiSimulatorListener + diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/configs/wokwiRunConfig.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/configs/wokwiRunConfig.kt new file mode 100644 index 0000000..21c0764 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/configs/wokwiRunConfig.kt @@ -0,0 +1,65 @@ +package com.github.jozott00.wokwiintellij.runner.configs + +import com.github.jozott00.wokwiintellij.runner.WokwiConfigurationFactory +import com.github.jozott00.wokwiintellij.runner.profileStates.WokwiSimulatorRunnerState +import com.github.jozott00.wokwiintellij.ui.WokwiIcons +import com.intellij.execution.Executor +import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.execution.configurations.ConfigurationTypeBase +import com.intellij.execution.configurations.RunConfigurationBase +import com.intellij.execution.configurations.RunConfigurationOptions +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.NotNullLazyValue +import javax.swing.JComponent +import javax.swing.JPanel + +class WokwiRunConfig( + project: Project, + factory: ConfigurationFactory, name: String +) : RunConfigurationBase(project, factory, name) { + + override fun getOptions(): WokwiRunConfigOptions { + return super.getOptions() as WokwiRunConfigOptions + } + + override fun getState(executor: Executor, environment: ExecutionEnvironment) = + WokwiSimulatorRunnerState(environment) + + override fun getConfigurationEditor() = WokwiRunEditor() + +} + +class WokwiRunConfigType : ConfigurationTypeBase( + ID, "Wokwi Run", "Run the Wokwi simulator and let it wait for a GDB debugger.", + NotNullLazyValue.createValue { WokwiIcons.Debug } +) { + init { + addFactory(WokwiConfigurationFactory(this)) + } + + companion object { + const val ID: String = "WowkiRunConfig" + } +} + + +class WokwiRunConfigOptions : RunConfigurationOptions() + + +class WokwiRunEditor : SettingsEditor() { + override fun resetEditorFrom(s: WokwiRunConfig) { + // currently no settings + } + + override fun applyEditorTo(s: WokwiRunConfig) { + // currently no settings + } + + override fun createEditor(): JComponent { + // currently no settings + return JPanel() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/configs/wokwiStartDebugConfig.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/configs/wokwiStartDebugConfig.kt new file mode 100644 index 0000000..17b4da0 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/configs/wokwiStartDebugConfig.kt @@ -0,0 +1,67 @@ +package com.github.jozott00.wokwiintellij.runner.configs + +import com.github.jozott00.wokwiintellij.runner.WokwiConfigurationFactory +import com.github.jozott00.wokwiintellij.runner.profileStates.WokwiSimulatorStartState +import com.github.jozott00.wokwiintellij.ui.WokwiIcons +import com.intellij.execution.Executor +import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.execution.configurations.ConfigurationTypeBase +import com.intellij.execution.configurations.RunConfigurationBase +import com.intellij.execution.configurations.RunConfigurationOptions +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.NotNullLazyValue +import javax.swing.JComponent +import javax.swing.JPanel + +class WokwiStartDebugConfig( + project: Project, + factory: ConfigurationFactory, name: String +) : RunConfigurationBase(project, factory, name) { + + override fun getOptions(): WokwiStartDebugConfigOptions { + return super.getOptions() as WokwiStartDebugConfigOptions + } + + override fun getState(executor: Executor, environment: ExecutionEnvironment) = + WokwiSimulatorStartState(project, true) +// override fun getState(executor: Executor, environment: ExecutionEnvironment) = +// WokwiSimulatorRunnerState(environment) + + override fun getConfigurationEditor() = WokwiStartDebugEditor() + +} + +class WokwiStartDebugConfigType : ConfigurationTypeBase( + ID, "Wokwi Start Debug", "Start the Wokwi simulator and let it wait for a GDB debugger.", + NotNullLazyValue.createValue { WokwiIcons.Debug } +) { + init { + addFactory(WokwiConfigurationFactory(this)) + } + + companion object { + const val ID: String = "WowkiStartDebugConfig" + } +} + + +class WokwiStartDebugConfigOptions : RunConfigurationOptions() + + +class WokwiStartDebugEditor : SettingsEditor() { + override fun resetEditorFrom(s: WokwiStartDebugConfig) { + // currently no settings + } + + override fun applyEditorTo(s: WokwiStartDebugConfig) { + // currently no settings + } + + override fun createEditor(): JComponent { + // currently no settings + return JPanel() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/macros/ElfPathMacro.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/macros/ElfPathMacro.kt new file mode 100644 index 0000000..0dfadd6 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/macros/ElfPathMacro.kt @@ -0,0 +1,20 @@ +package com.github.jozott00.wokwiintellij.runner.macros + +import com.github.jozott00.wokwiintellij.toml.WokwiConfigProcessor +import com.intellij.ide.macro.Macro +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +class ElfPathMacro : Macro() { + override fun getName() = "WokwiElfPath" + + override fun getDescription() = "Resolves to the ELF file path specified in the Wokwi configuration." + + override fun expand(dataContext: DataContext): String? { + val project = CommonDataKeys.PROJECT.getData(dataContext) ?: return null + val config = runBlocking(Dispatchers.IO) { WokwiConfigProcessor.findElfFile(project) } ?: return null + return config.path + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/macros/GdbServerMacro.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/macros/GdbServerMacro.kt new file mode 100644 index 0000000..e5ef516 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/macros/GdbServerMacro.kt @@ -0,0 +1,22 @@ +package com.github.jozott00.wokwiintellij.runner.macros + +import com.github.jozott00.wokwiintellij.toml.WokwiConfigProcessor +import com.intellij.ide.macro.Macro +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +class GdbServerMacro : Macro() { + override fun getName() = "WokwiGdbServer" + + override fun getDescription() = "Resolves to the Wokwi's GDB Server address" + + override fun expand(dataContext: DataContext): String? { + val project = CommonDataKeys.PROJECT.getData(dataContext) ?: return null + val config = runBlocking(Dispatchers.IO) { WokwiConfigProcessor.readConfig(project) } ?: return null + + val port = config.gdbServerPort ?: return null + return "localhost:$port" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/profileStates/WokwiSimulatorRunnerState.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/profileStates/WokwiSimulatorRunnerState.kt new file mode 100644 index 0000000..3c6cc91 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/profileStates/WokwiSimulatorRunnerState.kt @@ -0,0 +1,73 @@ +package com.github.jozott00.wokwiintellij.runner.profileStates + +import com.github.jozott00.wokwiintellij.runner.WokwiProcessHandler +import com.github.jozott00.wokwiintellij.services.WokwiProjectService +import com.intellij.execution.configurations.CommandLineState +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.progress.util.ProgressIndicatorBase +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import java.io.OutputStream + +//@Deprecated("The WokwiRunner is currently not used, and might be removed in the future.") +class WokwiSimulatorRunnerState(private val myEnvironment: ExecutionEnvironment) : CommandLineState(myEnvironment) { + override fun startProcess() = WokwiRunnerProcessHandler(myEnvironment.project) +} + +//@Deprecated("The WokwiRunner is currently not used, and might be removed in the future.") +class WokwiRunnerProcessHandler(val project: Project) : WokwiProcessHandler() { + + val wokwiService = project.service() + + override fun startNotify() { + super.startNotify() + + ProgressManager.getInstance().runProcessWithProgressAsynchronously( + object : Task.Backgroundable(project, "Wokwi execution", false) { + override fun run(indicator: ProgressIndicator) { + wokwiService.startSimulator(this@WokwiRunnerProcessHandler, false) + } + }, + ProgressIndicatorBase() + ) + + } + + override fun onShutdown() { + destroyProcess() + } + + override fun onTextAvailable(text: String, outputType: Key<*>) { + notifyTextAvailable(text, outputType) + } + + override fun destroyProcessImpl() { + thisLogger().info("Destroy Process") + wokwiService.stopSimulator() + notifyProcessTerminated(0) + } + + override fun detachProcessImpl() { + thisLogger().info("Detach Process") + notifyProcessDetached() + } + + override fun detachIsDefault() = false + + override fun getProcessInput(): OutputStream { + thisLogger().info("Ouput stream") + val stream = object : OutputStream() { + override fun write(b: Int) { + thisLogger().info("Got new input $b") + } + } + return stream + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/profileStates/WokwiSimulatorStartState.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/profileStates/WokwiSimulatorStartState.kt new file mode 100644 index 0000000..cd4c228 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/profileStates/WokwiSimulatorStartState.kt @@ -0,0 +1,52 @@ +package com.github.jozott00.wokwiintellij.runner.profileStates + +import com.github.jozott00.wokwiintellij.runner.WokwiProcessHandler +import com.github.jozott00.wokwiintellij.services.WokwiProjectService +import com.github.jozott00.wokwiintellij.simulator.args.WokwiArgs +import com.intellij.execution.ExecutionResult +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.runners.ProgramRunner +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project + +class WokwiSimulatorStartState(private val project: Project, private val waitForDebugger: Boolean) : RunProfileState { + override fun execute(executor: Executor?, runner: ProgramRunner<*>) = object : ExecutionResult { + override fun getExecutionConsole() = null + + override fun getActions(): Array { + return emptyArray() + } + + override fun getProcessHandler() = WokwiStartProcessHandler(project, waitForDebugger) +// override fun getProcessHandler() = WokwiRunnerProcessHandler(project) + } +} + + +class WokwiStartProcessHandler(project: Project, waitForDebugger: Boolean) : + WokwiProcessHandler() { + + private val wokwiService = project.service() + + init { + wokwiService.startSimulator(this, waitForDebugger) + } + + override fun destroyProcessImpl() { + notifyProcessTerminated(0) + } + + override fun detachProcessImpl() { + notifyProcessDetached() + } + + override fun detachIsDefault() = false + + override fun getProcessInput() = null + + override fun onStarted(runArgs: WokwiArgs) { + this.destroyProcess() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/runBefore/WokwiStartDebugBeforeRunTask.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/runBefore/WokwiStartDebugBeforeRunTask.kt new file mode 100644 index 0000000..933978d --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/runner/runBefore/WokwiStartDebugBeforeRunTask.kt @@ -0,0 +1,65 @@ +package com.github.jozott00.wokwiintellij.runner.runBefore + +import com.github.jozott00.wokwiintellij.services.WokwiProjectService +import com.github.jozott00.wokwiintellij.simulator.WokwiSimulatorListener +import com.github.jozott00.wokwiintellij.ui.WokwiIcons +import com.intellij.execution.BeforeRunTask +import com.intellij.execution.BeforeRunTaskProvider +import com.intellij.execution.configurations.RunConfiguration +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.components.service +import com.intellij.openapi.util.Key +import kotlinx.coroutines.* +import javax.swing.Icon + +class WokwiStartDebugBeforeRunTaskProvider : BeforeRunTaskProvider() { + + override fun getId(): Key = ID + + override fun getName() = "Start Wokwi Debug" + + override fun getIcon(): Icon = WokwiIcons.Debug + + override fun createTask(runConfiguration: RunConfiguration): WokwiStartDebugBeforeRunTask = + WokwiStartDebugBeforeRunTask() + + override fun executeTask( + context: DataContext, + configuration: RunConfiguration, + environment: ExecutionEnvironment, + task: WokwiStartDebugBeforeRunTask + ): Boolean { + val projectService = environment.project.service() + return runBlocking(Dispatchers.IO) { + // start child scope to make cancellation on dispose possible. + val job = projectService.childScope("WokwiStartBeforeRunTask").async { + val result = projectService.startSimulatorSuspended(task, true) + task.waitForSimulatorToBeRunning() + result + } + try { + job.await() + } catch (e: CancellationException) { + false + } + } + } + +} + +class WokwiStartDebugBeforeRunTask : + BeforeRunTask(ID), WokwiSimulatorListener { + + private val simulatorRunning = CompletableDeferred() + + suspend fun waitForSimulatorToBeRunning() { + simulatorRunning.await() + } + + override fun onRunning() { + simulatorRunning.complete(Unit) + } +} + +val ID: Key = Key.create("WokwiStartDebug.Before.Run") \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiArgsLoader.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiArgsLoader.kt new file mode 100644 index 0000000..220f8ba --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiArgsLoader.kt @@ -0,0 +1,98 @@ +package com.github.jozott00.wokwiintellij.services + +import arrow.core.Either +import com.github.jozott00.wokwiintellij.simulator.WokwiConfig +import com.github.jozott00.wokwiintellij.simulator.args.WokwiArgs +import com.github.jozott00.wokwiintellij.simulator.args.WokwiArgsFirmware +import com.github.jozott00.wokwiintellij.simulator.args.WokwiProjectType +import com.github.jozott00.wokwiintellij.utils.WokwiNotifier.notifyBalloonAsync +import com.github.jozott00.wokwiintellij.utils.simulation.FirmwareUtils +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.readBytes +import com.intellij.openapi.vfs.readText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Service(Service.Level.PROJECT) +class WokwiArgsLoader(val project: Project) { + + private var licensingService = ApplicationManager.getApplication().service() + + suspend fun load(config: WokwiConfig): WokwiArgs? { + val license = loadLicense() ?: return null + val diagram = readAction { config.diagram.readText() } + val firmware = loadFirmware(config.firmware) ?: return null + + val projectType = detectProject() + // TODO: Check for esp image + + val args = WokwiArgs(license, diagram, firmware) + return args + + } + + suspend fun loadFirmware(firmwareFile: VirtualFile): WokwiArgsFirmware? = withContext(Dispatchers.IO) { + if (!readAction { firmwareFile.exists() }) { + withContext(Dispatchers.EDT) { + notifyBalloonAsync( + title = "Failed to load firmware", + message = "Firmware `${firmwareFile.path}` does not exist and therefore cannot be loaded for simulation.", + NotificationType.ERROR + ) + } + return@withContext null + } + + val isFlasherArgsFile = firmwareFile.name == "flasher_args.json" + val binaryPaths = mutableListOf(firmwareFile.path) + + val buffer = if (isFlasherArgsFile) { + val packedResult= + when (val result = FirmwareUtils.packEspIdfFirmware(firmwareFile)) { + is Either.Left -> { + notifyBalloonAsync(result.value) + return@withContext null + } + is Either.Right -> result.value + } + + binaryPaths.addAll(packedResult.binaryPaths) + packedResult.img + } else { + readAction { firmwareFile.readBytes() } + } + + val format = FirmwareUtils.determineFirmwareFormat(firmwareFile, buffer) + + WokwiArgsFirmware( + buffer = buffer, + format = format, + rootFile = firmwareFile, + isFlasherFile = isFlasherArgsFile, + size = buffer.size.toUInt(), + binaryPaths = binaryPaths + ) + } + + @Suppress("SameReturnValue") + private fun detectProject(): WokwiProjectType { + return WokwiProjectType.RUST + } + + private suspend fun loadLicense() = licensingService.loadAndCheckLicense() + .onLeft { + notifyBalloonAsync( + title = it.title, + message = it.message, + type = NotificationType.ERROR + ) + }.getOrNull() + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiComponentService.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiComponentService.kt index da8fc50..65566af 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiComponentService.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiComponentService.kt @@ -1,27 +1,27 @@ package com.github.jozott00.wokwiintellij.services -import com.github.jozott00.wokwiintellij.states.WokwiConfigState -import com.github.jozott00.wokwiintellij.toolWindow.SimulatorPanel -import com.github.jozott00.wokwiintellij.toolWindow.WokwiToolWindow -import com.github.jozott00.wokwiintellij.toolWindow.wokwiConfigPanel +import com.github.jozott00.wokwiintellij.states.WokwiSettingsState +import com.github.jozott00.wokwiintellij.toolWindow.WokwiConsoleToolWindow +import com.github.jozott00.wokwiintellij.toolWindow.WokwiSimulationToolWindow +import com.github.jozott00.wokwiintellij.ui.config.wokwiConfigPanel import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import javax.swing.JPanel @Service(Service.Level.PROJECT) class WokwiComponentService(val project: Project) { - val wokwiConfigState = project.service() + private val wokwiConfigState = project.service() - val simulatorPanel = SimulatorPanel() - val configPanel = wokwiConfigPanel(wokwiConfigState.state) { + private val configPanel = wokwiConfigPanel(project, wokwiConfigState.state) { onChangeAction = { - println("Changes in model: ${wokwiConfigState.state}") + // do nothing } } - val toolWindow = WokwiToolWindow(configPanel, simulatorPanel) + val simulatorToolWindowComponent = WokwiSimulationToolWindow(configPanel) + val consoleToolWindowComponent = WokwiConsoleToolWindow(project) + } \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiDataService.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiDataService.kt deleted file mode 100644 index 419b5bc..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiDataService.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.github.jozott00.wokwiintellij.services - -import com.github.jozott00.wokwiintellij.states.WokwiConfigState -import com.github.jozott00.wokwiintellij.utils.WokwiNotifier -import com.intellij.notification.NotificationType -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.thisLogger -import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VfsUtil -import espimg.EspImg -import espimg.ImageResult -import espimg.exceptions.EspImgException -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.nio.file.Files -import java.nio.file.Paths -import java.nio.file.attribute.BasicFileAttributes - -@Service(Service.Level.PROJECT) -class WokwiDataService(val project: Project) { - - private val configState = project.service() - - private var lastFile: String? = null - private var lastModifiedTime: Long? = null - private var image: ImageResult? = null - - fun retrieveImage(): ImageResult? { - if (checkForReload()) - loadImage() - - return image - } - - private fun checkForReload(): Boolean { - if (configState.elfPath != lastFile) { - return true - } - - val path = configState.elfPath - try { - val currentTimeStamp = this.readFileModification(path) - - if (configState.elfPath != lastFile || currentTimeStamp != lastModifiedTime) { - println("RELOAD REQUIRED: ${configState.elfPath} vs $lastFile ... $currentTimeStamp vs $lastModifiedTime") - return true - } - } catch (e: IOException) { - println("RELOAD REQUIRED: EXCETPION $e") - return true - } - - - return false - } - - private fun loadImage(): Boolean { - val path = configState.elfPath - println("LOADING IMAGE $path") - val file = File(path) - - if (!file.exists()) { - thisLogger().warn("File $file does not exist!") - } - - val vfile = VfsUtil.findFileByIoFile(file, true) - - if (vfile == null) { - WokwiNotifier.notifyBalloon("ELF file `$path` not found", project, NotificationType.ERROR) - return false; - } - - val inputStream: InputStream = vfile.inputStream - - try { - this.image = EspImg.getFlashImage(inputStream.readAllBytes(), null, null) - this.lastModifiedTime = this.readFileModification(path) - } catch (e: EspImgException) { - WokwiNotifier.notifyBalloon("${e.message}", project, NotificationType.ERROR) - return false; - } - - lastFile = file.path - return true - } - - private fun readFileModification(path: String): Long { - val fileAttributes = Files.readAttributes(Paths.get(path), BasicFileAttributes::class.java) - return fileAttributes.lastModifiedTime().toMillis() - } - - -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiLicensingService.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiLicensingService.kt new file mode 100644 index 0000000..3f7a656 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiLicensingService.kt @@ -0,0 +1,147 @@ +package com.github.jozott00.wokwiintellij.services + +import ai.grazie.utils.mpp.Base64 +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.github.jozott00.wokwiintellij.WokwiConstants +import com.github.jozott00.wokwiintellij.exceptions.GenericError +import com.github.jozott00.wokwiintellij.utils.WokwiNotifier +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.ide.passwordSafe.PasswordSafe +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.logger +import io.ktor.http.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.nio.charset.StandardCharsets +import java.util.* + + +@Service(Service.Level.APP) +class WokwiLicensingService(private val cs: CoroutineScope) { + + private val licenseAttributes = + CredentialAttributes(WokwiConstants.WOWKI_PLUGIN_SERVICE_NAME, WokwiConstants.WOKWI_LICENCE_STORE_KEY) + + private var licenseCache: String? = null + + suspend fun getLicense() = licenseCache ?: withContext(Dispatchers.IO) { + PasswordSafe.instance.let { + licenseCache = it.getPassword(licenseAttributes) + licenseCache + } + } + + fun updateLicense(license: String) = cs.launch(Dispatchers.IO) { + LOG.info("Update Wokwi license") + licenseCache = license + PasswordSafe.instance.setPassword(licenseAttributes, license) + WokwiNotifier.notifyBalloonAsync("New Wokwi license activated", "You are ready to go!") + } + + @Suppress("unused") + fun removeLicense() = cs.launch(Dispatchers.IO) { + licenseCache = null + PasswordSafe.instance.setPassword(licenseAttributes, null) + WokwiNotifier.notifyBalloonAsync("Wokwi license removed", "Your license has been removed.") + } + + suspend fun loadAndCheckLicense(): Either { + val license = getLicense() ?: + return GenericError( + "No Wokwi license found", + "Set your Wokwi license in the Wokwi window.", + ).left() + + val licenseObj = parseLicense(license) ?: + return GenericError( + "Invalid Wokwi license", + "The Wokwi license could not be parsed.", + ).left() + + if (licenseObj.expiration < Date()) + return GenericError( + "Expired Wokwi license", + "The Wokwi license is expired, please refresh it.", + ).left() + + return license.right() + } + + + fun parseLicense(license: String): WokwiLicense? { + lateinit var decoded: ByteArray + try { + // Decoding the base64 input + val decodedString = base64DblClickDecode(license) + decoded = Base64.decode(decodedString) + } catch (e: Exception) { + return null + } + + // Finding the first null byte + val zeroIndex = decoded.indexOf(0) + if (zeroIndex < 0) { + return null + } + + // Parsing the URL parameters + val licenseText = String(decoded.sliceArray(0 until zeroIndex), StandardCharsets.UTF_8) + val params = licenseText.parseUrlEncodedParameters() + + val userId = params["u"] + val name = params["n"] + val email = params["e"] + val expirationStr = params["x"] + val plan = params["p"] + + if (userId == null || name == null || email == null || expirationStr == null) { + return null + } + + if (!Regex("^[0-9]{8}$").matches(expirationStr)) { + return null + } + + val year = expirationStr.substring(0, 4).toInt() + val month = expirationStr.substring(4, 6).toInt() - 1 + val day = expirationStr.substring(6, 8).toInt() + + val expiration = Calendar.getInstance().run { + set(year, month, day) + time + } + + return WokwiLicense(userId, name, email, expiration, plan) + } + + private fun base64DblClickDecode(value: String): String { + var result = value.replace("_P", "+") + .replace("_S", "/") + .replace("=", "") + while (result.length % 4 > 0) { + result += "=" + } + return result + } + + companion object { + val LOG = logger() + } + + + data class WokwiLicense( + val userId: String, + val name: String, + val email: String, + val expiration: Date, + val plan: String? + ) { + fun isValid(): Boolean { + return expiration.after(Date()) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiPluginDisposable.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiPluginDisposable.kt new file mode 100644 index 0000000..b46d181 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiPluginDisposable.kt @@ -0,0 +1,18 @@ +package com.github.jozott00.wokwiintellij.services + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project + +/** + * The service is intended to be used instead of a project as a parent disposable. + */ +@Service(Service.Level.PROJECT) +class WokwiPluginDisposable: Disposable { + companion object { + fun getInstance(project: Project) = project.service() + } + + override fun dispose() { } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiProjectService.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiProjectService.kt index 859113a..ff51a54 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiProjectService.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiProjectService.kt @@ -1,78 +1,215 @@ package com.github.jozott00.wokwiintellij.services -import com.github.jozott00.wokwiintellij.listeners.WokwiElfFileListener -import com.github.jozott00.wokwiintellij.states.WokwiConfigState +import com.github.jozott00.wokwiintellij.extensions.disposeByDisposer +import com.github.jozott00.wokwiintellij.simulator.WokwiSimulator +import com.github.jozott00.wokwiintellij.simulator.WokwiSimulatorListener +import com.github.jozott00.wokwiintellij.simulator.gdb.WokwiGDBServer +import com.github.jozott00.wokwiintellij.states.WokwiSettingsState +import com.github.jozott00.wokwiintellij.toml.WokwiConfigProcessor +import com.github.jozott00.wokwiintellij.toolWindow.ConsoleWindowFactory +import com.github.jozott00.wokwiintellij.ui.WokwiIcons +import com.github.jozott00.wokwiintellij.ui.console.SimulationConsole +import com.github.jozott00.wokwiintellij.utils.ToolWindowUtils import com.github.jozott00.wokwiintellij.utils.WokwiNotifier -import com.github.jozott00.wokwiintellij.wokwiServer.WokwiServer +import com.intellij.notification.NotificationType import com.intellij.openapi.Disposable +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.invokeLater import com.intellij.openapi.components.Service import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VirtualFileManager -import com.intellij.util.messages.MessageBusConnection +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.jcef.JBCefApp +import com.intellij.util.namedChildScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Service(Service.Level.PROJECT) -class WokwiProjectService(val project: Project) : Disposable { - private var server: WokwiServer? = null +class WokwiProjectService(val project: Project, private val cs: CoroutineScope) : Disposable { + + private var simulator: WokwiSimulator? = null + private var console: SimulationConsole? = null private val componentService = project.service() - private val configState = project.service() - private val dataService = project.service() - private val simulationService = project.service() - - private var msgBusConnection: MessageBusConnection? = null - private var simulationRunning = false; - fun startSimulator() { - if (dataService.retrieveImage() == null) { - return + private val settingsState = project.service() + private val argsLoader = project.service() + private var consoleToolWindow: ToolWindow? = null + private var gdbServer: WokwiGDBServer? = null + + @Suppress("UnstableApiUsage") + fun childScope(name: String) = cs.namedChildScope(name) + + fun startSimulator(withListener: WokwiSimulatorListener? = null, byDebugger: Boolean = false) { + cs.launch { + startSimulatorSuspended(withListener, byDebugger) + } + } + + suspend fun startSimulatorSuspended( + withListener: WokwiSimulatorListener? = null, + byDebugger: Boolean = false + ): Boolean { + LOG.info("Start simulator...") + + if (simulator == null || byDebugger) { + createNewSimulator(byDebugger) + } else { + updateFirmware() + }.also { if (!it) return false } + + + withListener?.let { simulator?.addSimulatorListener(it) } + simulator?.start() + + invokeLater { + ToolWindowUtils.setSimulatorIcon(project, true) + activateConsoleToolWindow() + } + + return true + } + + private suspend fun createNewSimulator(waitForDebugger: Boolean = false): Boolean { + + val config = + WokwiConfigProcessor.loadConfig( + project, + settingsState.wokwiConfigPath, + settingsState.wokwiDiagramPath + ) + ?: return false + val args = argsLoader.load(config) ?: return false + args.waitForDebugger = waitForDebugger + + simulator?.disposeByDisposer() + + if (!JBCefApp.isSupported()) { + WokwiNotifier.notifyBalloonAsync( + "Could not create Wokwi simulator", + "JCEF browser is not supported. Please report this issue on the wokwi-intellij Github repository.", + NotificationType.ERROR + ) + return false + } + configGDBServer( + waitForDebugger, + config.gdbServerPort ?: 3333 + ) // configures gdbServer for new simulator instance + + simulator = WokwiSimulator(args, this).also { + gdbServer?.let { server -> cs.launch { it.connectToGDBServer(server) } } // connect to server + } + + withContext(Dispatchers.EDT) { + val console = getConsole() + simulator?.addSimulatorListener(console) + + simulator?.let { componentService.simulatorToolWindowComponent.showSimulation(it.component) } + componentService.consoleToolWindowComponent.setConsole(console) } - componentService.toolWindow.showSimulation() - simulationRunning = true - watchStart() + return true + } + + private fun configGDBServer(shouldDebug: Boolean, port: Int) { + if (!shouldDebug) { + gdbServer?.disposeByDisposer() + gdbServer = null + } else { + gdbServer?.let { + if (!it.isRunning()) { + it.disposeByDisposer() + return@let + } + + it.resetEventChannel() + return + } + gdbServer = WokwiGDBServer(this.childScope("WokwiGDBServer"), this).also { + it.listen(port) + } + } } - fun stopSimulator() { - componentService.toolWindow.showConfig() - simulationRunning = false - watchStop() + private suspend fun updateFirmware(): Boolean { + simulator?.let { + val firmware = it.getFirmware().rootFile + val newFirmware = argsLoader.loadFirmware(firmware) ?: return false + it.setFirmware(newFirmware) + } + + return true } - fun startup() { - val port = 9012 // Specify your port here - server = WokwiServer(port, project).apply { - start() - println("WokwiServer started on port: $port") + fun stopSimulator() = cs.launch { + simulator?.disposeByDisposer() + simulator = null + + gdbServer?.disposeByDisposer() + gdbServer = null + + withContext(Dispatchers.EDT) { + ToolWindowUtils.setSimulatorIcon(project, false) + componentService.simulatorToolWindowComponent.showConfig() } } + override fun dispose() { - server?.stop() } - fun restartSimulation() { - simulationService.restartAll() + fun firmwareUpdated() = cs.launch { + WokwiNotifier.notifyBalloonAsync(title = "New firmware detected", "Restarting Wokwi simulator...") + startSimulatorSuspended() } - fun elfFileUpdate() { - println("FILE UPDATED ... restart") - WokwiNotifier.notifyBalloon("New build available, restarting simulation...", project) - simulationService.restartAll() + fun getWatchPaths(): List? { + return simulator?.getFirmware()?.binaryPaths } - fun watchStart() { - if (!configState.watchElf || !simulationRunning) return - println("START WATCHING") - msgBusConnection = project.messageBus.connect() - msgBusConnection?.subscribe(VirtualFileManager.VFS_CHANGES, WokwiElfFileListener(project)) + fun isSimulatorRunning(): Boolean { + return simulator != null } - fun watchStop() { - println("STOP WATCHING") - msgBusConnection?.disconnect() - msgBusConnection = null + private suspend fun getConsole(): SimulationConsole { + return withContext(Dispatchers.EDT) { + val console = this@WokwiProjectService.console ?: run { + val c = SimulationConsole(project) + Disposer.register(this@WokwiProjectService, c) + c + } + console + } + } + + private fun activateConsoleToolWindow() = cs.launch(Dispatchers.EDT) { + consoleToolWindow?.let { + it.show() + return@launch + } + + val consoleToolWindowId = "Wokwi Run" + + val tm = ToolWindowManager.getInstance(project) + if (tm.toolWindowIds.contains(consoleToolWindowId)) + return@launch + + consoleToolWindow = tm.registerToolWindow(consoleToolWindowId) { + val factory = ConsoleWindowFactory() + contentFactory = factory + icon = WokwiIcons.ConsoleToolWindowIcon + canCloseContent = false + } } + companion object { + private val LOG = logger() + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiSimulationService.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiSimulationService.kt deleted file mode 100644 index 31517bb..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/services/WokwiSimulationService.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.github.jozott00.wokwiintellij.services - -import com.github.jozott00.wokwiintellij.utils.WokwiNotifier -import com.github.jozott00.wokwiintellij.wokwiServer.WokwiCommand -import com.intellij.notification.NotificationType -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service -import com.intellij.openapi.project.Project -import com.jetbrains.rd.generator.nova.PredefinedType -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import org.java_websocket.WebSocket - -@Service(Service.Level.PROJECT) -class WokwiSimulationService(val project: Project) { - var connection: WebSocket? = null - - val dataService = project.service() - - - fun messageReceived(msg: Map, conn: WebSocket) { - - } - - fun connect(webSocket: WebSocket): Boolean { - if (this.connection != null) { - return false - } - - this.connection = webSocket - sendStart(webSocket) - - return true - } - - fun restartAll() { - if (connection != null) { - sendStart(connection!!) - } - } - - private fun sendStart(webSocket: WebSocket) { - val image = dataService.retrieveImage() - if (image == null) { - WokwiNotifier.notifyBalloon("Failed to retrieve image", project, NotificationType.ERROR) - return - } - - val cmd = WokwiCommand.start(image.elf, image.romSegments.toList()) - - val json = Json.encodeToString(WokwiCommand.serializer(), cmd) - webSocket.send(json) - } - - fun disconnect(webSocket: WebSocket) { - this.connection = null - } - -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/Command.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/Command.kt new file mode 100644 index 0000000..f14a6b1 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/Command.kt @@ -0,0 +1,62 @@ +package com.github.jozott00.wokwiintellij.simulator + +import com.beust.klaxon.json +import com.github.jozott00.wokwiintellij.simulator.args.FirmwareFormat + +@Suppress("unused") +object Command { + + fun start(diagram: String, firmware: String, firmwareFormat: FirmwareFormat, license: String, waitForDebugger: Boolean): String { + return json { + obj( + "command" to "start", + "diagram" to diagram, + "license" to license, + "firmware" to firmware, + "firmwareFormat" to firmwareFormat.toString(), + "firmwareB64" to true, + "pause" to waitForDebugger, + "useGateway" to false, // private gateways not yet supported + "disableSerialMonitor" to true, + ) + }.toJsonString() + } + + fun editor(diagram: String, license: String) = json { + obj( + "command" to "editor", + "diagram" to diagram, + "license" to license, + "chips" to array(), + "readonly" to false, + ) + }.toJsonString() + + + fun resourceData(buffer: String): String { + return json { + obj( + "command" to "resourceData", + "buffer" to buffer, + ) + }.toJsonString() + } + + fun gdbMessage(message: String): String { + return json { + obj( + "command" to "gdbMessage", + "message" to message, + ) + }.toJsonString() + } + + fun gdbBreak(): String { + return json { + obj( + "command" to "gdbBreak", + ) + }.toJsonString() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/Simulator.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/Simulator.kt new file mode 100644 index 0000000..c14ce19 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/Simulator.kt @@ -0,0 +1,16 @@ +package com.github.jozott00.wokwiintellij.simulator + +import com.github.jozott00.wokwiintellij.simulator.args.WokwiArgsFirmware +import com.github.jozott00.wokwiintellij.simulator.gdb.GDBServerCommunicator + +interface Simulator { + + fun start() + + fun setFirmware(firmware: WokwiArgsFirmware) + + fun getFirmware(): WokwiArgsFirmware + + suspend fun connectToGDBServer(server: GDBServerCommunicator) + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/WokwiConfig.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/WokwiConfig.kt new file mode 100644 index 0000000..df7c691 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/WokwiConfig.kt @@ -0,0 +1,13 @@ +@file:Suppress("unused") + +package com.github.jozott00.wokwiintellij.simulator + +import com.intellij.openapi.vfs.VirtualFile + +class WokwiConfig( + val version: String, + val elf: VirtualFile, + val firmware: VirtualFile, + val diagram: VirtualFile, + val gdbServerPort: Int? +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/WokwiSimulator.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/WokwiSimulator.kt new file mode 100644 index 0000000..91e0dae --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/WokwiSimulator.kt @@ -0,0 +1,226 @@ +package com.github.jozott00.wokwiintellij.simulator + + +import com.github.jozott00.wokwiintellij.jcef.BrowserPipe +import com.github.jozott00.wokwiintellij.simulator.args.WokwiArgs +import com.github.jozott00.wokwiintellij.simulator.args.WokwiArgsFirmware +import com.github.jozott00.wokwiintellij.simulator.gdb.GDBServerCommunicator +import com.github.jozott00.wokwiintellij.simulator.gdb.GDBServerEvent +import com.github.jozott00.wokwiintellij.ui.jcef.SimulatorJCEFHtmlPanel +import com.intellij.execution.process.AnsiEscapeDecoder +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.ui.ComponentContainer +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import com.intellij.util.containers.ContainerUtil +import io.ktor.util.* +import kotlinx.serialization.json.* +import java.net.URL +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +private val LOG = logger() + +class WokwiSimulator( + private val runArgs: WokwiArgs, + parentDisposable: Disposable, +) : Disposable, + Simulator, + ComponentContainer, + BrowserPipe.Subscriber { + + private var browserReady = false + private var startInvoked = false + + private val browser = SimulatorJCEFHtmlPanel(this) + private val browserPipe = browser.browserPipe + + private val myEventMulticaster = createEventMulticaster() + private val myListeners: MutableList = ContainerUtil.createLockFreeCopyOnWriteList() + + private val ansiEscapeDecoder = AnsiEscapeDecoder() + private var gdbServer: GDBServerCommunicator? = null + + private var simulationRunning = false + + init { + Disposer.register(parentDisposable, this) + browserPipe.subscribe(PIPE_TOPIC, this, this) + } + + override fun start() { + startInvoked = true + startInternal() + } + + private fun startInternal() { + simulationRunning = false + + // if browser not yet ready just return + if (!browserReady) return + if (!startInvoked) return + + LOG.info("(Re)starting simulation...") + + @OptIn(ExperimentalEncodingApi::class) + val firmwareString = Base64.encode(runArgs.firmware.buffer) + + val cmd = Command.start( + diagram = runArgs.diagram, + firmware = firmwareString, + firmwareFormat = runArgs.firmware.format, + license = runArgs.license, + waitForDebugger = runArgs.waitForDebugger + ) + browserPipe.send(PIPE_TOPIC, cmd) + myEventMulticaster.onStarted(runArgs) + } + + override fun setFirmware(firmware: WokwiArgsFirmware) { + runArgs.firmware = firmware + } + + override fun getFirmware(): WokwiArgsFirmware { + return runArgs.firmware + } + + override suspend fun connectToGDBServer(server: GDBServerCommunicator) { + gdbServer = server + server.getMessageFlow().collect { event -> + when (event) { + is GDBServerEvent.Connected -> {} + is GDBServerEvent.Error -> LOG.error("Error: ${event.error}") + is GDBServerEvent.Message -> { + browserPipe.send(PIPE_TOPIC, Command.gdbMessage(event.message)) + } + is GDBServerEvent.Break -> browserPipe.send(PIPE_TOPIC, Command.gdbBreak()) + } + } + } + + private fun startRecv() { + browserReady = true + startInternal() + } + + private fun uartDataRecv(data: JsonObject) { + val bytes = data["bytes"] + ?.jsonArray + ?.map { it.jsonPrimitive.int.toByte() } + ?.toByteArray() ?: run { + LOG.error("Malformed data received: No bytes: $data") + return + } + + if (bytes.isEmpty()) return + + val str = String(bytes, Charsets.UTF_8) + + ansiEscapeDecoder.escapeText(str, ProcessOutputTypes.STDOUT) { t, contentType -> + myEventMulticaster.onTextAvailable(t, contentType) + } + } + + private fun loadResourceRecv(req: JsonObject) { + // TODO: Make this offline + val urlString = req["url"]?.jsonPrimitive?.content ?: run { + LOG.error("Malformed data received: No url: $req") + return + } + val url = URL(urlString) + val resource = url.readBytes().encodeBase64() + val cmd = Command.resourceData(resource) + browserPipe.send(PIPE_TOPIC, cmd) + + checkSimulationStartedRunning() + } + + private fun gdbResponseRecv(req: JsonObject) { + val response = req["response"]?.jsonPrimitive?.content ?: run { + LOG.error("Malformed data received: No response field: $req") + return + } + gdbServer?.sendResponse(response) + } + + override fun messageReceived(data: String): Boolean { + val json = Json.parseToJsonElement(data).jsonObject + + val type: String = json["command"]?.jsonPrimitive?.content ?: run { + LOG.error("Malformed data received: $data") + return false + } + + when (type) { + "start" -> startRecv() + "loadResource" -> loadResourceRecv(json) + "uartData" -> uartDataRecv(json) // do nothing right now + "wifiFrame", "wifiConnect" -> { + TODO("Not yet implemented") + } // do nothing right now + "gdbResponse" -> gdbResponseRecv(json) + else -> { + LOG.warn("Unknown command: $type") + LOG.debug("Unknown command data: $data") + return false + } + } + + return true + } + + private fun checkSimulationStartedRunning() { + if (!simulationRunning) { + simulationRunning = true + myEventMulticaster.onRunning() + } + } + + companion object { + private const val PIPE_TOPIC = "wokwi" + } + + fun addSimulatorListener(listener: WokwiSimulatorListener) { + myListeners.add(listener) + } + + override fun dispose() { + createEventMulticaster().onShutdown() + myListeners.clear() + } + + override fun getComponent() = browser.component + + override fun getPreferredFocusableComponent() = component + + + private fun createEventMulticaster(): WokwiSimulatorListener { + return object : WokwiSimulatorListener { + override fun onStarted(runArgs: WokwiArgs) { + notifyAll { it.onStarted(runArgs) } + } + + override fun onShutdown() { + notifyAll { it.onShutdown() } + } + + override fun onTextAvailable(text: String, outputType: Key<*>) { + notifyAll { it.onTextAvailable(text, outputType) } + } + + override fun onRunning() { + notifyAll { it.onRunning() } + } + + private fun notifyAll(m: (WokwiSimulatorListener) -> Unit) { + for (l in myListeners) { + m(l) + } + } + + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/WokwiSimulatorListener.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/WokwiSimulatorListener.kt new file mode 100644 index 0000000..ef2e0f6 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/WokwiSimulatorListener.kt @@ -0,0 +1,11 @@ +package com.github.jozott00.wokwiintellij.simulator + +import com.github.jozott00.wokwiintellij.simulator.args.WokwiArgs +import com.intellij.openapi.util.Key + +interface WokwiSimulatorListener { + fun onStarted(runArgs: WokwiArgs) {} + fun onShutdown() {} + fun onTextAvailable(text: String, outputType: Key<*>) {} + fun onRunning() {} +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/args/WokwiArgs.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/args/WokwiArgs.kt new file mode 100644 index 0000000..ccd46bd --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/args/WokwiArgs.kt @@ -0,0 +1,42 @@ +@file:Suppress("unused") + +package com.github.jozott00.wokwiintellij.simulator.args + +import com.intellij.openapi.vfs.VirtualFile + + +class WokwiArgs( + val license: String, + val diagram: String, + var firmware: WokwiArgsFirmware, + var waitForDebugger: Boolean = false, +) + +@Suppress("unused") +class WokwiArgsFirmware( + val buffer: ByteArray, + val format: FirmwareFormat, + val rootFile: VirtualFile, + val isFlasherFile: Boolean, + val size: UInt, + val binaryPaths: List +) + +enum class FirmwareFormat { + HEX, + UF2, + BIN; + + override fun toString() = name.lowercase() +} + +enum class WokwiProjectType { + RUST, + ZEPHYR, + PLATFORMIO, + ESP_IDF, + PICO_SDK, + ARDUINO, + SMING, + UNKNOWN +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/args/WokwiArgsUtils.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/args/WokwiArgsUtils.kt new file mode 100644 index 0000000..10f1be1 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/args/WokwiArgsUtils.kt @@ -0,0 +1,2 @@ +package com.github.jozott00.wokwiintellij.simulator.args + diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/gdb/wokwiGDBServer.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/gdb/wokwiGDBServer.kt new file mode 100644 index 0000000..287d67f --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/simulator/gdb/wokwiGDBServer.kt @@ -0,0 +1,208 @@ +package com.github.jozott00.wokwiintellij.simulator.gdb + +import com.github.jozott00.wokwiintellij.utils.WokwiNotifier +import com.github.jozott00.wokwiintellij.utils.runCloseable +import com.intellij.notification.NotificationType +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.Closeable +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException + + +sealed class GDBServerEvent { + data object Connected : GDBServerEvent() + data class Error(val error: Throwable) : GDBServerEvent() + data class Message(val message: String) : GDBServerEvent() + data object Break : GDBServerEvent() +} + +interface GDBServerCommunicator { + fun getMessageFlow(): Flow + fun sendResponse(response: String) +} + + +class WokwiGDBServer(private val cs: CoroutineScope, parentDisposable: Disposable) : GDBServerCommunicator, Disposable { + + init { + Disposer.register(parentDisposable, this) + } + + private var serverSocket: ServerSocket? = null + private var currentMessageProcessor: MessageProcessor? = null + private var eventChannel = Channel { Channel.BUFFERED } + + fun listen(port: Int) = cs.launch(Dispatchers.IO) { + try { + ServerSocket(port).use { socket -> + serverSocket = socket + + LOG.info("GDB Server listening on port $port") + + while (true) { + val clientSocket = try { + socket.runCloseable { it.accept() } + } catch (e: SocketException) { + break + } + currentMessageProcessor?.close() + currentMessageProcessor = null + handleConnection(clientSocket) + } + } + } catch (e: Exception) { + LOG.warn(e) + WokwiNotifier.notifyBalloonAsync( + "Couldn't start GDB server", + "Failed to create server socket: ${e.message}", + NotificationType.ERROR + ) + } + } + + fun isRunning() = serverSocket?.isClosed?.not() ?: false + + private suspend fun handleConnection(socket: Socket) { + currentMessageProcessor = MessageProcessor(socket, eventChannel) + currentMessageProcessor?.process() + } + + override fun sendResponse(response: String) = cs.launch(Dispatchers.IO) { + currentMessageProcessor?.writeResponse(response) + }.let { } + + override fun dispose() { + currentMessageProcessor?.close() + currentMessageProcessor = null + + serverSocket?.close() + serverSocket = null + } + + + override fun getMessageFlow(): Flow { + return eventChannel.receiveAsFlow() + } + + fun resetEventChannel() { + eventChannel.close() + eventChannel = Channel { Channel.BUFFERED } + } + + companion object { + val LOG = logger() + } +} + +private class MessageProcessor(private val socket: Socket, private val eventChannel: Channel) : + Closeable { + + private val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + private val writer = PrintWriter(socket.getOutputStream(), true) + + suspend fun process() = socket.use { + writer.println("+") + + dispatchEvent(GDBServerEvent.Connected) + + var buf = "" + while (true) { + val data = try { + reader.read() + } catch (e: Exception) { + return@use + } + if (data == -1) + break + if (data == 3) { + LOG.debug("Received break") + dispatchEvent(GDBServerEvent.Break) + continue + } + buf += data.toChar() + while (shouldContinueProcessingMessage(buf)) { + val message = extractMessage(buf) + val receivedChecksum = extractChecksum(buf) + buf = trimProcessedParts(buf) + + if (calculateChecksum(message) != receivedChecksum) { + writer.println('-') // Negative acknowledgment + LOG.warn("Warning: GDB checksum error in message: $message") + } else { + writer.println('+') // Positive acknowledgment + + if (checkDetach(message)) + return@use + + dispatchEvent(GDBServerEvent.Message(message)) + } + } + } + } + + fun writeResponse(response: String) { + writer.println(response) + } + + private suspend fun dispatchEvent(event: GDBServerEvent) = withContext(Dispatchers.IO) { + eventChannel.send(event) + } + + + private fun shouldContinueProcessingMessage(buf: String): Boolean { + val dollar = buf.indexOf('$') + val hash = buf.indexOf('#') + return dollar > -1 && hash > -1 && hash > dollar && hash + 3 <= buf.length + } + + private fun extractMessage(buf: String): String { + val dollar = buf.indexOf('$') + val hash = buf.indexOf('#') + return buf.substring(dollar + 1, hash) + } + + private fun extractChecksum(buf: String): String { + val hash = buf.indexOf('#') + return buf.substring(hash + 1, hash + 3) + } + + private fun trimProcessedParts(buf: String): String { + val hash = buf.indexOf('#') + return buf.substring(hash + 3) + } + + private fun calculateChecksum(message: String): String { + val checksum = message.sumOf { it.code } and 0xff + return "${(checksum ushr 4).toString(16)}${(checksum and 0xf).toString(16)}" + } + + private fun checkDetach(message: String): Boolean { + if (message == "D") { + writer.println("+\$#00") + return true + } + return false + } + + companion object { + val LOG = logger() + } + + override fun close() { + if (!socket.isClosed) + socket.close() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/states/WokwiConfigState.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/states/WokwiConfigState.kt deleted file mode 100644 index 477b816..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/states/WokwiConfigState.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.github.jozott00.wokwiintellij.states - -import com.intellij.ide.util.PropertiesComponent -import com.intellij.openapi.components.PersistentStateComponent -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.State -import com.intellij.util.xmlb.XmlSerializerUtil - -@Service(Service.Level.PROJECT) -@State(name = "WokwiConfigModel") -data class WokwiConfigState( - var espDevice: ESPDevice = ESPDevice.ESP32, - var flashSize: FlashSize = FlashSize._4MB, - var elfPath: String = "", - var watchElf: Boolean = true, -) : PersistentStateComponent { - - override fun getState(): WokwiConfigState { - return this - } - - override fun loadState(state: WokwiConfigState) { - XmlSerializerUtil.copyBean(state, this) - } -} - -enum class ESPDevice { - ESP32, - ESP32s2, - ESP32s3, - ESP32c3, - ESP32c6; -} - -enum class FlashSize { - _2MB, - _4MB, - _8MB, - _16MB, - _32MB; - - override fun toString(): String { - return name.removePrefix("_").removeSuffix("MB") + " MB" - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/states/WokwiSettingsState.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/states/WokwiSettingsState.kt new file mode 100644 index 0000000..9c870d6 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/states/WokwiSettingsState.kt @@ -0,0 +1,24 @@ +package com.github.jozott00.wokwiintellij.states + +import com.github.jozott00.wokwiintellij.WokwiConstants +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.util.xmlb.XmlSerializerUtil + +@Service(Service.Level.PROJECT) +@State(name = "WokwiProjectSettings") +data class WokwiSettingsState( + var wokwiConfigPath: String = WokwiConstants.WOKWI_CONFIG_FILE, + var wokwiDiagramPath: String = WokwiConstants.WOKWI_DIAGRAM_FILE, + var watchFirmware: Boolean = true, +) : PersistentStateComponent { + + override fun getState(): WokwiSettingsState { + return this + } + + override fun loadState(state: WokwiSettingsState) { + XmlSerializerUtil.copyBean(state, this) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toml/Util.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toml/Util.kt new file mode 100644 index 0000000..87dbdc6 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/toml/Util.kt @@ -0,0 +1,34 @@ +package com.github.jozott00.wokwiintellij.toml + +import com.github.jozott00.wokwiintellij.WokwiConstants +import com.intellij.psi.PsiFile +import com.intellij.psi.util.childrenOfType +import org.toml.lang.psi.* +import org.toml.lang.psi.ext.TomlLiteralKind +import org.toml.lang.psi.ext.kind + +val PsiFile.isWokwiToml: Boolean get() = name == WokwiConstants.WOKWI_CONFIG_FILE + +val TomlKey.stringValue: String + get() { + return segments.map { it.name }.joinToString(".") + } + +val TomlValue.stringValue: String? + get() { + val kind = (this as? TomlLiteral)?.kind + return (kind as? TomlLiteralKind.String)?.value + } + +val TomlFile.tableList: List get() = childrenOfType() + + +fun TomlFile.findTable(key: String): TomlTable? { + return tableList.find { + it.header.key?.stringValue == key + } +} + +fun TomlKeyValueOwner.findValue(key: String): TomlValue? { + return entries.find { it.key.stringValue == key }?.value +} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toml/WokwiConfigProcessor.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toml/WokwiConfigProcessor.kt new file mode 100644 index 0000000..f66c4f2 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/toml/WokwiConfigProcessor.kt @@ -0,0 +1,185 @@ +package com.github.jozott00.wokwiintellij.toml + +import com.akuleshov7.ktoml.TomlInputConfig +import com.akuleshov7.ktoml.exceptions.TomlDecodingException +import com.akuleshov7.ktoml.file.TomlFileReader +import com.github.jozott00.wokwiintellij.WokwiConstants +import com.github.jozott00.wokwiintellij.extensions.findRelativeFiles +import com.github.jozott00.wokwiintellij.simulator.WokwiConfig +import com.github.jozott00.wokwiintellij.states.WokwiSettingsState +import com.github.jozott00.wokwiintellij.utils.NotifyAction +import com.github.jozott00.wokwiintellij.utils.WokwiNotifier +import com.github.jozott00.wokwiintellij.utils.WokwiTemplates +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.serializer + +object WokwiConfigProcessor { + + suspend fun loadConfig(project: Project, wokwiConfigPath: String, diagramPath: String): WokwiConfig? { + val absoluteWokwiPath = findWokwiConfigPath(wokwiConfigPath, project) ?: return null + val diagramFilePath = findWokwiDiagramPath(diagramPath, project) ?: return null + val tomlConfig = withContext(Dispatchers.IO) { + readConfig(absoluteWokwiPath, project) + } ?: return null + return withContext(Dispatchers.IO) { + loadConfig(project, tomlConfig, absoluteWokwiPath, diagramFilePath) + } + } + + suspend fun readConfig(project: Project): WokwiTomlTable? { + val projectSettings = project.service() + val configFile = findWokwiConfigPath(projectSettings.wokwiConfigPath, project) ?: return null + return readConfig(configFile, project) + } + + suspend fun findElfFile(project: Project): VirtualFile? { + val projectSettings = project.service() + val configFile = findWokwiConfigPath(projectSettings.wokwiConfigPath, project) ?: return null + val tomlConfig = readConfig(project) ?: return null + return configFile.parent.findFileByRelativePath(tomlConfig.elf) + } + + private suspend fun readConfig(configFile: VirtualFile, project: Project): WokwiTomlTable? { + + if (!configFile.exists()) { + notifyError("Configuration file `${configFile.path}` not found.") + return null + } + + if (configFile.name != "wokwi.toml") { + notifyError("Wokwi configuration file must be called `wokwi.toml` but is actually `${configFile.name}`") + return null + } + + val fileReader = TomlFileReader( + inputConfig = TomlInputConfig( + ignoreUnknownNames = true, + allowNullValues = true + ) + ) + + lateinit var model: WokwiTomlConfig + try { + model = fileReader.decodeFromFile(serializer(), configFile.path) + } catch (e: TomlDecodingException) { + notifyError( + "Check your wokwi.toml file and try again", + getNotifyJumpToAction("Jump to config", project, configFile) + ) + return null + } + + return model.wokwi + } + + private suspend fun loadConfig( + project: Project, + tomlConfig: WokwiTomlTable, + configFile: VirtualFile, + diagramFile: VirtualFile + ): WokwiConfig? { + val configDir = readAction { configFile.parent } + + val elfFile = readAction { configDir.findFileByRelativePath(tomlConfig.elf) } ?: run { + notifyError( + "Invalid ELF path. Is the project already built?", + getNotifyJumpToAction("Jump to config", project, configFile) + ) + return null + } + + val firmwareFile = readAction { configDir.findFileByRelativePath(tomlConfig.firmware) } ?: run { + notifyError( + "Invalid firmware path. Is the project already built?", + getNotifyJumpToAction("Jump to config", project, configFile) + ) + return null + } + + + return WokwiConfig( + version = tomlConfig.version.toString(), + elf = elfFile, + firmware = firmwareFile, + diagram = diagramFile, + gdbServerPort = tomlConfig.gdbServerPort + ) + } + + + private suspend fun notifyError(error: String, action: NotifyAction? = null) { + withContext(Dispatchers.EDT) { + WokwiNotifier.notifyBalloonAsync( + "Couldn't load Wokwi configuration", + error, + NotificationType.ERROR, + action + ) + } + } + + @Suppress("SameParameterValue") + private fun getNotifyJumpToAction(text: String, project: Project, file: VirtualFile) = NotifyAction(text) { _, _ -> + val descriptor = OpenFileDescriptor(project, file) + FileEditorManager.getInstance(project).openTextEditor(descriptor, true) + } + + private suspend fun findWokwiConfigPath(wokwiConfigPath: String, project: Project): VirtualFile? = withContext(Dispatchers.IO) { readAction { project + .findRelativeFiles(wokwiConfigPath)}.run { + if (isEmpty()) { + WokwiNotifier.notifyBalloon( + "Configuration file `$wokwiConfigPath` not found in project." + ) + return@run null + } + if (size > 1) { + notifyError("Found multiple configuration files: \n${joinToString("\n")}. \nSpecify the concrete one in the Settings.") + return@run null + } + + return@run first() + }} + + private suspend fun findWokwiDiagramPath(wokwiDiagramPath: String, project: Project): VirtualFile? = withContext(Dispatchers.IO) {readAction { project + .findRelativeFiles(wokwiDiagramPath) }.run { + if (isEmpty()) { + notifyError( + "Diagram file `$wokwiDiagramPath` not found in project.", + NotifyAction("Create diagram.json") { _, _ -> + val psiManager = PsiManager.getInstance(project) + val virtualFile = project.guessProjectDir() ?: return@NotifyAction + val psiDir = psiManager.findDirectory(virtualFile) + WriteCommandAction.runWriteCommandAction(project) { + val diagramFile = + psiDir?.createFile(WokwiConstants.WOKWI_DIAGRAM_FILE) ?: return@runWriteCommandAction + val document = diagramFile.viewProvider.document + document.setText(WokwiTemplates.defaultDiagramJson()) + val descriptor = + OpenFileDescriptor(project, diagramFile.virtualFile) + FileEditorManager.getInstance(project).openTextEditor(descriptor, true) + } + } + ) + return@run null + } + if (size > 1) { + notifyError("Found multiple diagram files: \n${joinToString("\n")}. \nSpecify the concrete one in the Settings.") + return@run null + } + return@run first() + }} + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toml/WokwiTomlConfig.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toml/WokwiTomlConfig.kt new file mode 100644 index 0000000..93f0473 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/toml/WokwiTomlConfig.kt @@ -0,0 +1,17 @@ +package com.github.jozott00.wokwiintellij.toml + +import kotlinx.serialization.Serializable + + +@Serializable +data class WokwiTomlConfig( + val wokwi: WokwiTomlTable +) + +@Serializable +data class WokwiTomlTable( + val version: Int, + val elf: String, + val firmware: String, + val gdbServerPort: Int? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/ConsoleWindowFactory.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/ConsoleWindowFactory.kt new file mode 100644 index 0000000..6480e00 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/ConsoleWindowFactory.kt @@ -0,0 +1,24 @@ +package com.github.jozott00.wokwiintellij.toolWindow + +import com.github.jozott00.wokwiintellij.services.WokwiComponentService +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.content.ContentFactory + +class ConsoleWindowFactory : ToolWindowFactory { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val componentService = toolWindow.project.service() + + val consoleToolWindow = componentService.consoleToolWindowComponent + val content = ContentFactory.getInstance() + .createContent(consoleToolWindow, null, false) + + toolWindow.contentManager.addContent(content) + } + + @Deprecated("Use isApplicableAsync", ReplaceWith("isApplicableAsync")) + override fun isApplicable(project: Project): Boolean = false + +} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/JCEFContent: JPanel().kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/JCEFContent: JPanel().kt deleted file mode 100644 index 1a716a0..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/JCEFContent: JPanel().kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.github.jozott00.wokwiintellij.toolWindow - -import com.intellij.openapi.Disposable -import com.intellij.ui.jcef.JBCefBrowser -import com.intellij.ui.jcef.JBCefBrowserBase -import com.intellij.ui.jcef.JBCefClient.Properties.JS_QUERY_POOL_SIZE -import com.intellij.ui.jcef.JBCefJSQuery -import com.intellij.ui.jcef.JCEFHtmlPanel -import org.cef.browser.CefBrowser -import org.cef.browser.CefFrame -import org.cef.handler.CefLoadHandlerAdapter -import java.awt.BorderLayout -import javax.swing.JPanel - - -class JCEFContent(onLoaded: (JCEFContent) -> Unit) : JPanel(), Disposable { - - val browser: JBCefBrowser = - JCEFHtmlPanel("https://wokwi.com/_alpha/wembed/345144250522927698?partner=espressif&port=9012&data=demo") - - init { - browser.let { - layout = BorderLayout() - add(it.component, BorderLayout.CENTER) - } - } - - init { - browser.jbCefClient.setProperty(JS_QUERY_POOL_SIZE, 5) - browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { - override fun onLoadEnd(cefBrowser: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) { - println("-------------- LOAD END") - - val loadedCallback = JBCefJSQuery.create(browser as JBCefBrowserBase) - loadedCallback.addHandler { _ -> - onLoaded(this@JCEFContent) - null - } - - cefBrowser!!.executeJavaScript( - """ - document.getElementsByTagName("header")[0].style.display = "none"; - document.body.style.overflow = "hidden"; - - var parentDiv = document.querySelector('.simulation_simulationControls__Jqtsp'); - var childDivs = parentDiv.children; - - // Sleep for a little to delay simulator show up - setTimeout(function(){ - ${loadedCallback.inject(null)} - }, 300); - - """.trimIndent(), null, 0 - ) - - } - }, browser.cefBrowser) - } - - override fun dispose() { - browser.dispose() - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/SimulatorPanel.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/SimulatorPanel.kt deleted file mode 100644 index 0cbb88c..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/SimulatorPanel.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.github.jozott00.wokwiintellij.toolWindow - - -import com.intellij.ide.wizard.withVisualPadding -import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.ui.components.Label -import com.intellij.ui.dsl.builder.Align -import com.intellij.ui.dsl.builder.LabelPosition -import com.intellij.ui.dsl.builder.panel -import com.intellij.ui.util.preferredWidth -import java.awt.BorderLayout -import java.awt.CardLayout -import java.awt.FlowLayout -import javax.swing.JPanel -import javax.swing.JProgressBar - -class SimulatorPanel : JPanel() { - - private val cardLayout = CardLayout() - private var browser: JCEFContent? = null - - private val loadingPanel = panel { - panel { - row { - cell(JProgressBar().apply { - isIndeterminate = true - preferredWidth = 300 - }) - .label("Starting simulator...", LabelPosition.TOP) - - } - }.align(Align.CENTER) - - }.withVisualPadding() - - - init { - layout = cardLayout - add(loadingPanel) - add("LOADING", loadingPanel) - cardLayout.show(this, "LOADING") - } - - - fun loadSimulator() { - browser = JCEFContent { browser -> - cardLayout.show(this, "SIMULATOR") - revalidate() - repaint() - } - - val simulator = simulator(toolbar(), browser!!) - add("SIMULATOR", simulator) - revalidate() - repaint() - } - - fun stopSimulator() { - remove(browser) - browser?.dispose() - - cardLayout.show(this, "LOADING") - - revalidate() - repaint() - } - - private fun simulator(toolbar: JPanel, browser: JPanel): JPanel { - val simulator = JPanel(BorderLayout()) - simulator.add(toolbar, BorderLayout.NORTH) - simulator.add(browser!!, BorderLayout.CENTER) - - return simulator - } - - private fun toolbar(): JPanel { - - val panel = JPanel(BorderLayout()) - - val am = ActionManager.getInstance() - val group = DefaultActionGroup( - am.getAction("com.github.jozott00.wokwiintellij.actions.WokwiStopAction"), - am.getAction("com.github.jozott00.wokwiintellij.actions.WokwiRestartAction"), - am.getAction("com.github.jozott00.wokwiintellij.actions.WokwiWatchAction"), - ) - val toolbar = am.createActionToolbar("com.github.jozott00.wokwiintellij.actions.WokwiToolbar", group, false) - // horizonal orientation - toolbar.orientation = 0 - toolbar.targetComponent = panel - panel.add(toolbar.component, BorderLayout.NORTH) - - - return panel - } - - private fun updateLayout() { - layout = CardLayout() - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/SimulatorWindowFactory.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/SimulatorWindowFactory.kt index f614881..ee78372 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/SimulatorWindowFactory.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/SimulatorWindowFactory.kt @@ -1,30 +1,22 @@ package com.github.jozott00.wokwiintellij.toolWindow -import com.github.jozott00.wokwiintellij.actions.WokwiRestartAction import com.github.jozott00.wokwiintellij.services.WokwiComponentService -import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentFactory -import com.intellij.ui.dsl.builder.panel -import org.jdesktop.swingx.action.ActionManager -import javax.swing.JPanel class SimulatorWindowFactory : ToolWindowFactory { - init { - thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.") - } - override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { val componentService = toolWindow.project.service() - val toolWindowContent = componentService.toolWindow.getContent() + val toolWindowContent = componentService.simulatorToolWindowComponent + val content = ContentFactory.getInstance() .createContent(toolWindowContent, null, false) + toolWindow.contentManager.addContent(content) } diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiConfigPanel.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiConfigPanel.kt deleted file mode 100644 index 894de0a..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiConfigPanel.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.github.jozott00.wokwiintellij.toolWindow - -import com.github.jozott00.wokwiintellij.states.ESPDevice -import com.github.jozott00.wokwiintellij.states.FlashSize -import com.github.jozott00.wokwiintellij.states.WokwiConfigState -import com.intellij.icons.AllIcons -import com.intellij.ide.wizard.withVisualPadding -import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.application.invokeLater -import com.intellij.openapi.ui.DialogPanel -import com.intellij.openapi.ui.TextFieldWithBrowseButton -import com.intellij.ui.dsl.builder.* -import com.intellij.ui.dsl.gridLayout.HorizontalAlign -import com.intellij.ui.util.preferredWidth -import java.awt.Font -import javax.swing.* - - -class WokwiConfigPanelBuilder(val model: WokwiConfigState) { - - var onChangeAction: (() -> Unit)? = null - - fun build(): DialogPanel { - var panel: DialogPanel? = null - val action = ActionManager.getInstance().getAction("com.github.jozott00.wokwiintellij.actions.WokwiStartAction") - - fun onChange() { - invokeLater { - if (panel == null) - return@invokeLater - - panel!!.apply() - onChangeAction?.invoke() - } - } - - panel = panel { - lateinit var textField: Cell - - row { - button("Start Simulator", action) - .align(Align.CENTER) - .apply { - this.component.icon = AllIcons.Debugger.ThreadRunning - } - } - - group("Simulation Settings") { - row("ESP target device:") { - comboBox(ESPDevice.entries) - .onChanged { _ -> onChange() } - .bindItem(model::espDevice.toNullableProperty()) - }.rowComment("Wokwi simulator diagram is chosen based on target device.") - - row("Executable: ") { - textField = textFieldWithBrowseButton().apply { - component.preferredWidth = 300 - } - .onChanged { _ -> onChange() } - .bindText(model::elfPath) - }.rowComment("Path to ELF binary to run in simulator") - - row("Flash Size: ") { - comboBox(FlashSize.entries) - .onChanged { _ -> onChange() } - .bindItem(model::flashSize.toNullableProperty()) - }.rowComment("Must be compatible with device and partition table") - } - } - .withVisualPadding() - - - return panel - } - -} - -fun wokwiConfigPanel(model: WokwiConfigState, build: WokwiConfigPanelBuilder.() -> Unit): DialogPanel { - return WokwiConfigPanelBuilder(model).apply(build).build() -} - -private fun JComponent.bold(isBold: Boolean) { - font = font.deriveFont(if (isBold) Font.BOLD else Font.PLAIN) -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiConsoleToolWindow.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiConsoleToolWindow.kt new file mode 100644 index 0000000..75cd6e9 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiConsoleToolWindow.kt @@ -0,0 +1,74 @@ +package com.github.jozott00.wokwiintellij.toolWindow + +import com.github.jozott00.wokwiintellij.extensions.wokwiDisposable +import com.github.jozott00.wokwiintellij.ui.console.SimulationConsole +import com.intellij.execution.ui.layout.impl.JBRunnerTabs +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.Project +import com.intellij.ui.tabs.TabInfo +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + + +@Suppress("SameParameterValue") +class WokwiConsoleToolWindow(project: Project) : + JPanel() { + private val tabs: WokwiConsoleTabs = WokwiConsoleTabs(project, project.wokwiDisposable) + private val controlActionGroup = createControlActionGroup() + + private val actionPlace = "WokwiConsole.topMiddleToolbar" + private val wrapper = ConsoleWrapper() + + init { + tabs.apply { + addTab(createTabInfo("Console", wrapper)) + } + + layout = BorderLayout() + add(tabs, BorderLayout.CENTER) + } + + fun setConsole(console: SimulationConsole) { + wrapper.setConsole(console) + } + + private fun createTabInfo(title: String, content: JComponent): TabInfo { + return TabInfo(content).apply { + text = title + setActions(controlActionGroup, actionPlace) + } + } + + private fun createControlActionGroup(): ActionGroup { + val am = ActionManager.getInstance() + return DefaultActionGroup().apply { + add(am.getAction("com.github.jozott00.wokwiintellij.actions.WokwiRestartAction")) + add(am.getAction("com.github.jozott00.wokwiintellij.actions.WokwiStopAction")) + addSeparator() + add(am.getAction("com.github.jozott00.wokwiintellij.actions.WokwiWatchAction")) + } + + } + + + class ConsoleWrapper : JPanel() { + init { + layout = BorderLayout() + } + + fun setConsole(console: SimulationConsole) { + removeAll() + add(console) + repaint() + } + + + } + + class WokwiConsoleTabs(project: Project, parentDisposable: Disposable) : + JBRunnerTabs(project, parentDisposable) +} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiSimulationToolWindow.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiSimulationToolWindow.kt new file mode 100644 index 0000000..72de128 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiSimulationToolWindow.kt @@ -0,0 +1,28 @@ +package com.github.jozott00.wokwiintellij.toolWindow + +import com.intellij.openapi.ui.DialogPanel +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +class WokwiSimulationToolWindow(private val configPanel: DialogPanel) : + JPanel() { + + init { + this.layout = BorderLayout() + this.add(configPanel) + } + + fun showSimulation(simulator: JComponent) { + this.removeAll() + this.add(simulator) + this.repaint() + } + + fun showConfig() { + this.removeAll() + this.add(configPanel) + this.repaint() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiToolWindow.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiToolWindow.kt deleted file mode 100644 index 45b066c..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/toolWindow/WokwiToolWindow.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.jozott00.wokwiintellij.toolWindow - -import com.intellij.openapi.ui.DialogPanel -import java.awt.BorderLayout -import javax.swing.JPanel - -class WokwiToolWindow(private val configPanel: DialogPanel, private val simulationPanel: SimulatorPanel) { - - private val panel = JPanel(BorderLayout()).apply { - this.add(configPanel) - - } - - fun getContent() = panel - - fun showSimulation() { - if (panel.components.contains(simulationPanel)) return - panel.removeAll() - panel.add(simulationPanel) - simulationPanel.loadSimulator() - panel.revalidate() - panel.repaint() - } - - fun showConfig() { - if (panel.components.contains(configPanel)) return - panel.removeAll() - panel.add(configPanel) - simulationPanel.stopSimulator() - panel.revalidate() - panel.repaint() - } - - -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/WokwiIcons.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/WokwiIcons.kt new file mode 100644 index 0000000..1df21fc --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/WokwiIcons.kt @@ -0,0 +1,32 @@ +package com.github.jozott00.wokwiintellij.ui + +import com.intellij.icons.AllIcons +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.LayeredIcon +import com.intellij.util.IconUtil +import javax.swing.Icon +import javax.swing.SwingConstants + +object WokwiIcons { + + private val Default = IconLoader.getIcon("icons/pluginIcon.svg", WokwiIcons.javaClass) + + val SimulatorToolWindowIcon = IconLoader.getIcon("icons/pluginIcon@13x13.svg", WokwiIcons.javaClass) + + val ConsoleToolWindowIcon = IconLoader.getIcon("icons/logIcon@13x13.svg", WokwiIcons.javaClass) + + val ConfigFile = IconLoader.getIcon("icons/pluginIcon@16x16.svg", WokwiIcons.javaClass) + + val Debug = LayeredIcon(2).also { + it.setIcon(Default, 0) + it.setIcon(Overlays.Debug, 1, SwingConstants.SOUTH_EAST) + } + + object Overlays { + val Debug: Icon = IconUtil.scale(AllIcons.Actions.StartDebugger, null, 0.8f) + } + +} + + + diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/config/LicensingDialog.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/config/LicensingDialog.kt new file mode 100644 index 0000000..154c906 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/config/LicensingDialog.kt @@ -0,0 +1,78 @@ +@file:Suppress("DialogTitleCapitalization") + +package com.github.jozott00.wokwiintellij.ui.config + +import com.github.jozott00.wokwiintellij.WokwiConstants +import com.github.jozott00.wokwiintellij.services.WokwiLicensingService +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.rows +import java.util.* + +class LicensingDialog : DialogWrapper(true) { + + private var licenseKeyArea: JBTextArea? = null + private val licensingService = ApplicationManager.getApplication().service() + + init { + title = "Activate Wokwi License" + + init() + } + + override fun createCenterPanel() = panel { + row { + label("Get your license from wokwi.com and paste it below to use the Wokwi simulator.") + } + row { + button("Open wokwi.com") { + BrowserUtil.browse("https://wokwi.com/license?v=${WokwiConstants.WOKWI_WCODE_VERSION}") + } + } + separator() + row { + label("Enter license key:") + } + row { + licenseKeyArea = textArea() + .apply { + component.lineWrap = true + component.wrapStyleWord = true + } + .onChanged { + initValidation() + } + .rows(5) + .align(Align.FILL) + .component + } + } + + + override fun doValidate(): ValidationInfo? { + val license = licenseKeyArea?.text + if (license.isNullOrEmpty()) { + return ValidationInfo("License key cannot be empty") + } + val wokwiLicense = licensingService.parseLicense(license) + ?: return ValidationInfo("Invalid license key") + + + if (wokwiLicense.expiration < Date()) { + return ValidationInfo("License has expired") + } + + return null + } + + override fun doOKAction() { + super.doOKAction() + licenseKeyArea?.text?.let { licensingService.updateLicense(it) } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/config/LicensingPanel.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/config/LicensingPanel.kt new file mode 100644 index 0000000..1a01f63 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/config/LicensingPanel.kt @@ -0,0 +1,113 @@ +@file:Suppress("SameParameterValue") + +package com.github.jozott00.wokwiintellij.ui.config + +import com.github.jozott00.wokwiintellij.services.WokwiLicensingService +import com.github.jozott00.wokwiintellij.utils.runInBackground +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.components.service +import com.intellij.openapi.ui.ComponentContainer +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.AsyncProcessIcon +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.awt.CardLayout +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel + + +class LicensingPanel : ComponentContainer { + + private val licensingService = ApplicationManager.getApplication().service() + + private val licenseNotSetPanel: JPanel = buildStatus(false, "No license activated!") + private val licenseInvalidPanel: JPanel = buildStatus(false, "License invalid!") + private val licenseExpiredPanel: JPanel = buildStatus(false, "License expired!") + private val licensePlanPanel = JLabel() + private val licenseSetPanel: JPanel = buildStatus(true, "License set", licensePlanPanel) + + private val statusCardLayout = CardLayout() + private val statusCard = JPanel(statusCardLayout).also { + it.add("LOADING", AsyncProcessIcon("Loading")) + it.add("LICENSE_MISSING", licenseNotSetPanel) + it.add("LICENSE_INVALID", licenseInvalidPanel) + it.add("LICENSE_EXPIRED", licenseExpiredPanel) + it.add("LICENSE_SET", licenseSetPanel) + } + + override fun getComponent() = panel { + row { + button("Activate License") { + LicensingDialog().show() + checkLicenseAvailability(true) + } + +// button("Remove License") { +// licensingService.removeLicense() +// checkLicenseAvailability(true) +// } + cell(statusCard) + + } + row { + comment("Wokwi requires a license to run the simulator. All features supported by the plugin are community license features and therefore free.") + } + }.also { + checkLicenseAvailability() + } + + @Suppress("SameParameterValue") + private fun checkLicenseAvailability(recentlyChanged: Boolean = false) = runInBackground{ runBlocking(Dispatchers.IO) { + if (recentlyChanged) { + invokeLater { statusCardLayout.show(statusCard, "LOADING") } + delay(500) + } + + val raw = licensingService.getLicense() + ?: return@runBlocking invokeLater { + statusCardLayout.show(statusCard, "LICENSE_MISSING") + } + + invokeLater { + val parsed = licensingService.parseLicense(raw) + val statusPanel = when { + parsed == null -> "LICENSE_INVALID" + !parsed.isValid() -> "LICENSE_EXPIRED" + else -> { + licensePlanPanel.text = "(${parsed.plan ?: "Community"})" + "LICENSE_SET" + } + } + statusCardLayout.show(statusCard, statusPanel) + } + } } + + private fun buildStatus(valid: Boolean, message: String, plan: JLabel? = null) = panel { + row { + icon(if (valid) AllIcons.RunConfigurations.TestPassed else AllIcons.RunConfigurations.TestFailed) + .gap(RightGap.SMALL) + label(message) + .gap(RightGap.SMALL) + + plan?.let { + cell(plan) + } + } + } + + override fun dispose() { + + } + + override fun getPreferredFocusableComponent(): JComponent { + return component + } + +} + + diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/config/WokwiConfigPanel.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/config/WokwiConfigPanel.kt new file mode 100644 index 0000000..98f6021 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/config/WokwiConfigPanel.kt @@ -0,0 +1,115 @@ +package com.github.jozott00.wokwiintellij.ui.config + +import com.github.jozott00.wokwiintellij.states.WokwiSettingsState +import com.intellij.icons.AllIcons +import com.intellij.ide.wizard.withVisualPadding +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.util.preferredWidth +import kotlin.io.path.pathString +import kotlin.io.path.relativeTo + + +class WokwiConfigPanelBuilder(val project: Project, private val model: WokwiSettingsState) { + + var onChangeAction: (() -> Unit)? = null + + fun build(): DialogPanel { + var panel: DialogPanel? = null + val action = ActionManager.getInstance().getAction("com.github.jozott00.wokwiintellij.actions.WokwiStartAction") + + fun onChange() { + if (panel == null) + return + + panel!!.apply() + onChangeAction?.invoke() + } + + panel = panel { + row { + button("Start Simulator", action) + .align(Align.CENTER) + .apply { + this.component.icon = AllIcons.Debugger.ThreadRunning + } + } + + group("License") { + row { + cell(LicensingPanel().component) + } + + } + + group("Settings") { + row("wokwi.toml path: ") { + textFieldWithBrowseButton { getRootRelativePathOf(it) }.apply { + component.preferredWidth = 400 + } + .validationOnInput { + ValidationInfo("Hello world", it) + } + .validationOnApply { + this.error("Test error") + } + .onChanged { + onChange() + } + .bindText(model::wokwiConfigPath) + + } + + row { + comment("The wokwi.toml holds all information the plugin needs to know. Visit the wokwi.toml docs for more information.") + } + .bottomGap(BottomGap.SMALL) + + + row("diagram.json path: ") { + textFieldWithBrowseButton { getRootRelativePathOf(it) }.apply { + component.preferredWidth = 400 + } + .onChanged { _ -> onChange() } + .bindText(model::wokwiDiagramPath) + } + + row { + comment("The diagram.json specifies the simulation runtime environment. Visit the diagram.json docs for more information.") + } + } + }.apply { + autoscrolls = true + } + .withVisualPadding() + + + return panel + } + + private fun getRootRelativePathOf(file: VirtualFile): String { + val projectPath = project.guessProjectDir()?.toNioPath() ?: return file.path + val resolved = projectPath.resolve(file.path) + val relative = resolved.relativeTo(projectPath) + return relative.pathString + } + + +} + +fun wokwiConfigPanel( + project: Project, + model: WokwiSettingsState, + build: WokwiConfigPanelBuilder.() -> Unit +): DialogPanel { + return WokwiConfigPanelBuilder(project, model).apply(build).build() +} + diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/console/SimulationConsole.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/console/SimulationConsole.kt new file mode 100644 index 0000000..2df5ba9 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/console/SimulationConsole.kt @@ -0,0 +1,49 @@ +package com.github.jozott00.wokwiintellij.ui.console + +import com.github.jozott00.wokwiintellij.simulator.WokwiSimulatorListener +import com.github.jozott00.wokwiintellij.simulator.args.WokwiArgs +import com.intellij.execution.filters.TextConsoleBuilderFactory +import com.intellij.execution.ui.ConsoleView +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import java.awt.BorderLayout +import javax.swing.JPanel + + +class SimulationConsole(project: Project) : JPanel(), Disposable, WokwiSimulatorListener { + + private var executionRunning = false + + private val consoleView: ConsoleView = TextConsoleBuilderFactory.getInstance().createBuilder(project).console +// private val consoleView = LanguageConsoleBuilder() +// .executionEnabled { this@SimulationConsole.executionRunning } +// .oneLineInput(true) +// .build(project, PlainTextLanguage.INSTANCE) + + init { + Disposer.register(this, consoleView) + + this.layout = BorderLayout() + add(consoleView.component) + } + + override fun onTextAvailable(text: String, outputType: Key<*>) { + consoleView.print(text, ConsoleViewContentType.getConsoleViewType(outputType)) + } + + override fun onStarted(runArgs: WokwiArgs) { + executionRunning = true + consoleView.clear() + } + + override fun onShutdown() { + executionRunning = false + } + + override fun dispose() { + // nothing to do + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/jcef/ResourceLoader.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/jcef/ResourceLoader.kt new file mode 100644 index 0000000..bda0b5b --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/jcef/ResourceLoader.kt @@ -0,0 +1,14 @@ +package com.github.jozott00.wokwiintellij.ui.jcef + +object ResourceLoader { + class Resource( + val content: ByteArray, + val type: String? = null + ) + + fun loadInternalResource(cls: Class, path: String, contentType: String?): Resource? { + return cls.getResourceAsStream(path)?.use { + Resource(it.readBytes(), contentType) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/jcef/SimulatorJCEFHtmlPanel.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/jcef/SimulatorJCEFHtmlPanel.kt new file mode 100644 index 0000000..4f3c62c --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/ui/jcef/SimulatorJCEFHtmlPanel.kt @@ -0,0 +1,20 @@ +package com.github.jozott00.wokwiintellij.ui.jcef + +import com.github.jozott00.wokwiintellij.jcef.impl.JcefBrowserPipe +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.jcef.JCEFHtmlPanel + +class SimulatorJCEFHtmlPanel(parentDisposable: Disposable) : + JCEFHtmlPanel(true, null, null) { + + val browserPipe = JcefBrowserPipe(this, this) + + init { + Disposer.register(parentDisposable, this) + val resource = ResourceLoader.loadInternalResource(this.javaClass, "/jcef/simulator/index.html", "text/html") + super.loadHTML(resource?.content?.toString(Charsets.UTF_8) ?: "

Not Found

") + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/ToolWindowUtils.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/ToolWindowUtils.kt new file mode 100644 index 0000000..c5332a7 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/ToolWindowUtils.kt @@ -0,0 +1,19 @@ +package com.github.jozott00.wokwiintellij.utils + +import com.github.jozott00.wokwiintellij.WokwiConstants +import com.github.jozott00.wokwiintellij.ui.WokwiIcons +import com.intellij.execution.runners.ExecutionUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindowManager + +object ToolWindowUtils { + + fun setSimulatorIcon(project: Project, live: Boolean) { + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(WokwiConstants.TOOL_WINDOW_SIM_ID) + var icon = WokwiIcons.SimulatorToolWindowIcon + if (live) + icon = ExecutionUtil.getLiveIndicator(icon) + toolWindow?.setIcon(icon) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/WokwiNotifier.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/WokwiNotifier.kt index 49fa244..47ec987 100644 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/WokwiNotifier.kt +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/WokwiNotifier.kt @@ -1,19 +1,55 @@ package com.github.jozott00.wokwiintellij.utils -import com.intellij.notification.NotificationGroup -import com.intellij.notification.NotificationGroupManager -import com.intellij.notification.NotificationType -import com.intellij.openapi.project.Project +import com.github.jozott00.wokwiintellij.exceptions.GenericError +import com.intellij.notification.* +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.EDT +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext object WokwiNotifier { - private val NOTIFICATION_GROUP = "Wokwi Simulator" + private const val NOTIFICATION_GROUP = "Wokwi Simulator" - fun notifyBalloon(message: String, project: Project, type: NotificationType = NotificationType.INFORMATION) { - NotificationGroupManager.getInstance() - .getNotificationGroup(NOTIFICATION_GROUP) - .createNotification(message, type) - .notify(project) + + fun notifyBalloon( + title: String, + message: String = "", + type: NotificationType = NotificationType.INFORMATION, + action: NotifyAction? = null + ) { + val notification = pluginNotifications().createNotification(title, message, type) + action?.let { notification.addAction(it) } + Notifications.Bus.notify(notification) + } + + suspend fun notifyBalloonAsync(error: GenericError, action: NotifyAction? = null) { + notifyBalloonAsync(error.title, error.message, NotificationType.ERROR, action) + } + + suspend fun notifyBalloonAsync( + title: String, + message: String = "", + type: NotificationType = NotificationType.INFORMATION, + action: NotifyAction? = null + ) { + withContext(Dispatchers.EDT) { + val notification = pluginNotifications().createNotification(title, message, type) + action?.let { notification.addAction(it) } + Notifications.Bus.notify(notification) + } + } + + private fun pluginNotifications(): NotificationGroup { + return NotificationGroupManager.getInstance().getNotificationGroup(NOTIFICATION_GROUP) + } + +} + + +class NotifyAction(text: String, val action: (AnActionEvent, Notification) -> Unit) : NotificationAction(text) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + action(e, notification) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/WokwiTemplates.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/WokwiTemplates.kt new file mode 100644 index 0000000..b23e19e --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/WokwiTemplates.kt @@ -0,0 +1,29 @@ +package com.github.jozott00.wokwiintellij.utils + +import org.intellij.lang.annotations.Language + +object WokwiTemplates { + + fun defaultDiagramJson(): String { + @Language("JSON") + val diagram = """ + { + "version": 1, + "editor": "wokwi", + "author": "Cool Dude", + "parts": [{ + "type": "board-esp32-s3-devkitc-1", + "id": "esp", + "top": 0.59, + "left": 0.67, + "attrs": { + "flashSize": "16" + } + }], + "connections": [ [ "esp:TX", "${'$'}serialMonitor:RX", "", [] ], [ "esp:RX", "${'$'}serialMonitor:TX", "", [] ] ] + } + """.trimIndent() + + return diagram + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/applicationUtils.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/applicationUtils.kt new file mode 100644 index 0000000..97b8074 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/applicationUtils.kt @@ -0,0 +1,7 @@ +package com.github.jozott00.wokwiintellij.utils + +import com.intellij.openapi.application.ApplicationManager + +fun runInBackground(task: () -> Unit) { + ApplicationManager.getApplication().executeOnPooledThread(task) +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/coroutineUtils.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/coroutineUtils.kt new file mode 100644 index 0000000..4078c19 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/coroutineUtils.kt @@ -0,0 +1,20 @@ +package com.github.jozott00.wokwiintellij.utils + +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.Closeable +import kotlin.coroutines.resume + +@Suppress("unused") +suspend inline fun T.useCancellably( + crossinline block: (T) -> R +): R = suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { this?.close() } + cont.resume(use(block)) +} + +suspend inline fun T.runCloseable( + crossinline block: (T) -> R +): R = suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { this?.close() } + cont.resume(block(this)) +} diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/simulation/FirmwareUtils.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/simulation/FirmwareUtils.kt new file mode 100644 index 0000000..0a718a0 --- /dev/null +++ b/src/main/kotlin/com/github/jozott00/wokwiintellij/utils/simulation/FirmwareUtils.kt @@ -0,0 +1,112 @@ +package com.github.jozott00.wokwiintellij.utils.simulation + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.github.jozott00.wokwiintellij.exceptions.GenericError +import com.github.jozott00.wokwiintellij.exceptions.catchIllArg +import com.github.jozott00.wokwiintellij.extensions.hexStringToByteArray +import com.github.jozott00.wokwiintellij.simulator.args.FirmwareFormat +import com.intellij.openapi.application.readAction +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.readBytes +import com.intellij.openapi.vfs.readText +import io.ktor.util.collections.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +object FirmwareUtils { + + private const val MAX_FIRMWARE_SIZE = 16 * 1024 * 1024 + private val LOG = logger() + + private val jsonParser = Json { ignoreUnknownKeys = true } + + suspend fun packEspIdfFirmware(flasherArgs: VirtualFile): Either = withContext(Dispatchers.IO) { + LOG.info("Packing ESP-IDF image from flasher-args '${flasherArgs.path}'") + fun buildErrorResult(message: String) = GenericError("Failed to build image from flasher_args.json", message).left() + + val flasherArgsString = readAction { flasherArgs.readText() } + val flasherJson = catchIllArg { jsonParser.decodeFromString(flasherArgsString) } + .getOrElse { + thisLogger().warn(it) + return@withContext buildErrorResult("Unable to parse content of flasher_args.json") } + + + val partPaths = ConcurrentSet() + + // list of (offset, data) + val firmwareParts = flasherJson.flashFiles.entries.map {e -> + val offset = e.key.removePrefix("0x").toIntOrNull(16) + ?: return@withContext buildErrorResult("Offset '${e.key}' is invalid") + + val partFile = flasherArgs.parent.findFileByRelativePath(e.value) + ?: return@withContext buildErrorResult("Firmware part '${e.value}' could not be found.") + + val data = readAction { partFile.readBytes() } + partPaths.add(partFile.path) + Pair(offset, data) + } + + val firmwareSize = firmwareParts.maxOf { it.first + it.second.size } + if (firmwareSize > MAX_FIRMWARE_SIZE) + return@withContext buildErrorResult("Firmware size ($firmwareSize bytes) exceeds the maximum supported size ($MAX_FIRMWARE_SIZE bytes)") + + val firmwareData = ByteArray(firmwareSize) + firmwareParts.forEach { (offset, data) -> + data.copyInto(firmwareData, offset) + } + + EspIdfPackResult(firmwareData, partPaths.toList()).right() + } + + fun determineFirmwareFormat(file: VirtualFile, content: ByteArray): FirmwareFormat { + val isUf2 = content.size >= 512 && isUf2Block(content.sliceArray(0 until 512)) + if (isUf2) + return FirmwareFormat.UF2 + + val fileExtension = file.extension?.lowercase() + val format = when (fileExtension) { + "hex" -> FirmwareFormat.HEX + else -> FirmwareFormat.BIN + } + + return format + } + + private fun isUf2Block(block: ByteArray): Boolean { + if (block.size != 512) + return false + + val firstMagicNumber = block.sliceArray(0 until 4) + val expectedFirstMagicNumber = "0x0A324655".hexStringToByteArray()!! + if (!firstMagicNumber.contentEquals(expectedFirstMagicNumber)) + return false + + val secondMagicNumber = block.sliceArray(4 until 8) + val expectedSecondMagicNumber = "0x9E5D5157".hexStringToByteArray()!! + if (!secondMagicNumber.contentEquals(expectedSecondMagicNumber)) + return false + + val finalMagicNumber = block.sliceArray(block.size - 4 until block.size) + val expectedFinalMagicNumber = "0x0AB16F30".hexStringToByteArray()!! + return finalMagicNumber.contentEquals(expectedFinalMagicNumber) + } + + + class EspIdfPackResult( + val img: ByteArray, + val binaryPaths: List + ) +} + +@Serializable +private data class FlasherJson( + @SerialName("flash_files") + val flashFiles: Map +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/wokwiServer/WokwiCommand.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/wokwiServer/WokwiCommand.kt deleted file mode 100644 index b015ca9..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/wokwiServer/WokwiCommand.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.github.jozott00.wokwiintellij.wokwiServer - -import espimg.RomSegment -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import java.util.* - - -@Serializable -data class WokwiCommand( - val type: String, - val elf: String, - val espBin: List> -) { - companion object { - fun start(bytes: ByteArray, segments: List): WokwiCommand { - val bootloader = segments[0] - val partitionTable = segments[1] - val app = segments[2] - - return WokwiCommand( - type = "start", - elf = bytes.toBase64(), - espBin = listOf( - romSegToVec(bootloader), - romSegToVec(partitionTable), - romSegToVec(app), - ) - ) - } - - private fun romSegToVec(romSeg: RomSegment): List { - return listOf( - JsonPrimitive(romSeg.addr), - JsonPrimitive(romSeg.data.toBase64()), - ) - } - } -} - -private fun ByteArray.toBase64(): String = - String(Base64.getEncoder().encode(this)) - - - diff --git a/src/main/kotlin/com/github/jozott00/wokwiintellij/wokwiServer/WokwiServer.kt b/src/main/kotlin/com/github/jozott00/wokwiintellij/wokwiServer/WokwiServer.kt deleted file mode 100644 index 674b8f7..0000000 --- a/src/main/kotlin/com/github/jozott00/wokwiintellij/wokwiServer/WokwiServer.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.github.jozott00.wokwiintellij.wokwiServer - -import com.github.jozott00.wokwiintellij.services.WokwiProjectService -import com.github.jozott00.wokwiintellij.services.WokwiSimulationService -import com.intellij.openapi.components.service -import com.intellij.openapi.components.services -import com.intellij.openapi.diagnostic.thisLogger -import com.intellij.openapi.project.Project -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import org.java_websocket.WebSocket -import org.java_websocket.handshake.ClientHandshake -import org.java_websocket.server.WebSocketServer -import java.net.InetSocketAddress - - -// WokwiServer class -class WokwiServer(port: Int, project: Project) : WebSocketServer(InetSocketAddress(port)) { - - val service = project.service() - - override fun onOpen(conn: WebSocket, handshake: ClientHandshake) { - service.connect(conn) - } - - override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) { - service.disconnect(conn) - } - - override fun onMessage(conn: WebSocket, message: String) { - val data = Json.parseToJsonElement(message) - require(data is JsonObject) { "Failed to parse received message $message" } - this.service.messageReceived(data, conn) - } - - override fun onStart() { - - } - - override fun onError(conn: WebSocket?, ex: Exception) { - ex.printStackTrace() - } -} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index e93bcfe..e9cc48c 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -5,39 +5,52 @@ jozott00 com.intellij.modules.platform + org.toml.lang - messages.MyBundle + messages.WokwiBundle + + - - + + + + + + + - - - + + + + description="Restart Wokwi simulation" + text="Restart Wokwi Simulation" + icon="AllIcons.Actions.Rerun" + />` @@ -62,6 +75,8 @@ + + diff --git a/src/main/resources/META-INF/pluginIconColorful.svg b/src/main/resources/META-INF/pluginIconColorful.svg new file mode 100644 index 0000000..9f76a06 --- /dev/null +++ b/src/main/resources/META-INF/pluginIconColorful.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/META-INF/toml-only.xml b/src/main/resources/META-INF/toml-only.xml new file mode 100644 index 0000000..e4103c2 --- /dev/null +++ b/src/main/resources/META-INF/toml-only.xml @@ -0,0 +1,37 @@ + + messages.WokwiBundle + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/icons/logIcon@13x13.svg b/src/main/resources/icons/logIcon@13x13.svg new file mode 100644 index 0000000..a0daaee --- /dev/null +++ b/src/main/resources/icons/logIcon@13x13.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/logIcon@13x13_dark.svg b/src/main/resources/icons/logIcon@13x13_dark.svg new file mode 100644 index 0000000..88d55b2 --- /dev/null +++ b/src/main/resources/icons/logIcon@13x13_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/logIcon@16x16.svg b/src/main/resources/icons/logIcon@16x16.svg new file mode 100644 index 0000000..462665d --- /dev/null +++ b/src/main/resources/icons/logIcon@16x16.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/main/resources/icons/logIcon@16x16_dark.svg b/src/main/resources/icons/logIcon@16x16_dark.svg new file mode 100644 index 0000000..58d7e20 --- /dev/null +++ b/src/main/resources/icons/logIcon@16x16_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/main/resources/icons/logIcon@20x20.svg b/src/main/resources/icons/logIcon@20x20.svg new file mode 100644 index 0000000..20e2970 --- /dev/null +++ b/src/main/resources/icons/logIcon@20x20.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/main/resources/icons/logIcon@20x20_dark.svg b/src/main/resources/icons/logIcon@20x20_dark.svg new file mode 100644 index 0000000..bdbc051 --- /dev/null +++ b/src/main/resources/icons/logIcon@20x20_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/main/resources/icons/pluginIcon.svg b/src/main/resources/icons/pluginIcon.svg index 7f3b453..023e313 100644 --- a/src/main/resources/icons/pluginIcon.svg +++ b/src/main/resources/icons/pluginIcon.svg @@ -1,45 +1,10 @@ - - - - - - + + + + + + + + diff --git a/src/main/resources/icons/pluginIcon@13x13.svg b/src/main/resources/icons/pluginIcon@13x13.svg new file mode 100644 index 0000000..00246ef --- /dev/null +++ b/src/main/resources/icons/pluginIcon@13x13.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/pluginIcon@13x13_dark.svg b/src/main/resources/icons/pluginIcon@13x13_dark.svg new file mode 100644 index 0000000..528396a --- /dev/null +++ b/src/main/resources/icons/pluginIcon@13x13_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/pluginIcon@16x16.svg b/src/main/resources/icons/pluginIcon@16x16.svg new file mode 100644 index 0000000..f54624d --- /dev/null +++ b/src/main/resources/icons/pluginIcon@16x16.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/icons/pluginIcon@16x16_dark.svg b/src/main/resources/icons/pluginIcon@16x16_dark.svg new file mode 100644 index 0000000..207e8c2 --- /dev/null +++ b/src/main/resources/icons/pluginIcon@16x16_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/icons/pluginIcon@20x20.svg b/src/main/resources/icons/pluginIcon@20x20.svg new file mode 100644 index 0000000..023e313 --- /dev/null +++ b/src/main/resources/icons/pluginIcon@20x20.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/icons/pluginIcon@20x20_dark.svg b/src/main/resources/icons/pluginIcon@20x20_dark.svg new file mode 100644 index 0000000..2fd5249 --- /dev/null +++ b/src/main/resources/icons/pluginIcon@20x20_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/icons/pluginIcon_dark.svg b/src/main/resources/icons/pluginIcon_dark.svg index f1ca467..2fd5249 100644 --- a/src/main/resources/icons/pluginIcon_dark.svg +++ b/src/main/resources/icons/pluginIcon_dark.svg @@ -1,6 +1,10 @@ - - - - + + + - \ No newline at end of file + + + + + + diff --git a/src/main/resources/inspectionDescriptions/ConfigVersion.html b/src/main/resources/inspectionDescriptions/ConfigVersion.html new file mode 100644 index 0000000..db9f1b4 --- /dev/null +++ b/src/main/resources/inspectionDescriptions/ConfigVersion.html @@ -0,0 +1,5 @@ + + +Currently, only version 1 wokwi configurations are supported. + + \ No newline at end of file diff --git a/src/main/resources/inspectionDescriptions/ElfFirmware.html b/src/main/resources/inspectionDescriptions/ElfFirmware.html new file mode 100644 index 0000000..1c6c39c --- /dev/null +++ b/src/main/resources/inspectionDescriptions/ElfFirmware.html @@ -0,0 +1,3 @@ + +The paths to the binaries must be valid to run the Wokwi simulator. + \ No newline at end of file diff --git a/src/main/resources/inspectionDescriptions/MissingConfiguration.html b/src/main/resources/inspectionDescriptions/MissingConfiguration.html new file mode 100644 index 0000000..1b01161 --- /dev/null +++ b/src/main/resources/inspectionDescriptions/MissingConfiguration.html @@ -0,0 +1,5 @@ + + +Some configurations that are required by Wokwi are not set in the wokwi.toml. + + \ No newline at end of file diff --git a/src/main/resources/jcef/simulator/index.html b/src/main/resources/jcef/simulator/index.html new file mode 100644 index 0000000..e6d488c --- /dev/null +++ b/src/main/resources/jcef/simulator/index.html @@ -0,0 +1,85 @@ + + + + Full-Width and Full-Height Iframe + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/messages/MyBundle.properties b/src/main/resources/messages/MyBundle.properties deleted file mode 100644 index 2e041d8..0000000 --- a/src/main/resources/messages/MyBundle.properties +++ /dev/null @@ -1,3 +0,0 @@ -projectService=Project service: {0} -randomLabel=The random number is: {0} -shuffle=Shuffle diff --git a/src/main/resources/messages/WokwiBundle.properties b/src/main/resources/messages/WokwiBundle.properties new file mode 100644 index 0000000..2b8efc6 --- /dev/null +++ b/src/main/resources/messages/WokwiBundle.properties @@ -0,0 +1,12 @@ +config.inspection.missing.wokwi.problem.descriptor=Missing Wokwi configuration +config.inspection.missing.wokwi.quickfix=Add Wokwi configuration +config.inspection.missing.version.problem.descriptor=Missing configuration: version not specified +config.inspection.missing.version.quickfix=Add version +config.inspection.missing.elf.problem.descriptor=Missing configuration: elf not specified +config.inspection.missing.elf.quickfix=Add elf path +config.inspection.missing.firmware.problem.descriptor=Missing configuration: firmware not specified +config.inspection.missing.firmware.quickfix=Add firmware path +config.inspection.version.invalid.problem.descriptor=Invalid version number: only version 1 supported +config.inspection.version.invalid.quickfix.change=Set version to {0} +config.inspection.binary.invalid.string.descriptor=Invalid path: String expected +config.inspection.binary.invalid.path.descriptor=Invalid path: {0} not found. \n\nHint: Path must be relative to wokwi.toml diff --git a/src/test/kotlin/com/github/jozott00/wokwiintellij/MyPluginTest.kt b/src/test/kotlin/com/github/jozott00/wokwiintellij/MyPluginTest.kt deleted file mode 100644 index 93b4109..0000000 --- a/src/test/kotlin/com/github/jozott00/wokwiintellij/MyPluginTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.jozott00.wokwiintellij - -import com.intellij.ide.highlighter.XmlFileType -import com.intellij.openapi.components.service -import com.intellij.psi.xml.XmlFile -import com.intellij.testFramework.TestDataPath -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.util.PsiErrorElementUtil - -@TestDataPath("\$CONTENT_ROOT/src/test/testData") -class MyPluginTest : BasePlatformTestCase() { - - fun testXMLFile() { - val psiFile = myFixture.configureByText(XmlFileType.INSTANCE, "bar") - val xmlFile = assertInstanceOf(psiFile, XmlFile::class.java) - - assertFalse(PsiErrorElementUtil.hasErrors(project, xmlFile.virtualFile)) - - assertNotNull(xmlFile.rootTag) - - xmlFile.rootTag?.let { - assertEquals("foo", it.name) - assertEquals("bar", it.value.text) - } - } - - fun testRename() { - myFixture.testRename("foo.xml", "foo_after.xml", "a2") - } - - - override fun getTestDataPath() = "src/test/testData/rename" -} diff --git a/src/test/testData/rename/foo.xml b/src/test/testData/rename/foo.xml deleted file mode 100644 index b21e9f2..0000000 --- a/src/test/testData/rename/foo.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 1>Foo - diff --git a/src/test/testData/rename/foo_after.xml b/src/test/testData/rename/foo_after.xml deleted file mode 100644 index 980ca96..0000000 --- a/src/test/testData/rename/foo_after.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Foo -