From b34f1005b21e1ed4e7f769b57c91fe40d172b67b Mon Sep 17 00:00:00 2001 From: maarten2000ha Date: Sun, 29 Mar 2026 19:24:17 +0200 Subject: [PATCH 1/7] Feat: Updated gradle to use kotlin version Gradle Kotlin adds more support within the Intelij IDEA and it's used in many examples --- gradle/libs.versions.toml | 2 +- .../intellij/wizard/HytaleModuleBuilder.kt | 60 +++++++++---------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3fd64a1..a175b0c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ opentest4j = "1.3.0" # plugins changelog = "2.5.0" -intelliJPlatform = "2.10.5" +intelliJPlatform = "2.11.0" kotlin = "2.2.21" kover = "0.9.3" qodana = "2025.2.2" diff --git a/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleModuleBuilder.kt b/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleModuleBuilder.kt index 33e4bdd..3c9d7f2 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleModuleBuilder.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleModuleBuilder.kt @@ -698,85 +698,85 @@ class HytaleModuleBuilder : ModuleBuilder() { private fun generateBuildGradle(basePath: String) { val isKotlin = language == "Kotlin" - val kotlinPlugin = if (isKotlin) "\n id 'org.jetbrains.kotlin.jvm' version '2.3.0'" else "" - val kotlinDeps = if (isKotlin) "\n implementation 'org.jetbrains.kotlin:kotlin-stdlib'" else "" - val compileTask = if (isKotlin) "compileKotlin" else "compileJava" + val kotlinPlugin = if (isKotlin) "\n kotlin(\"jvm\") version\"2.3.0\"" else "" + val kotlinDeps = if (isKotlin) "\n implementation(\"org.jetbrains.kotlin:kotlin-stdlib\")" else "" - File(basePath, "build.gradle").writeText(""" + File(basePath, "build.gradle.kts").writeText(""" plugins { - id 'java'$kotlinPlugin - id 'com.gradleup.shadow' version '8.3.0' + id("java") + id("com.gradleup.shadow") version "8.3.0"$kotlinPlugin } - group = '$packageName' - version = '$version' + group = "$packageName" + version = "$version" repositories { mavenCentral() // Official Hytale Maven repository maven { - name = 'hytale-release' - url = 'https://maven.hytale.com/release' + name = "hytale-release" + url = uri("https://maven.hytale.com/release") } maven { - name = 'hytale-pre-release' - url = 'https://maven.hytale.com/pre-release' + name = "hytale-pre-release" + url = uri("https://maven.hytale.com/pre-release") } } dependencies { // Hytale Server API from official Maven repository - compileOnly 'com.hypixel.hytale:Server:2026.01.24-6e2d4fc36' + // + notes the latest compatible version + compileOnly("com.hypixel.hytale:Server:+") // JSR305 annotations (@Nonnull, @Nullable) - compileOnly 'com.google.code.findbugs:jsr305:3.0.2' - implementation 'com.google.code.gson:gson:2.10.1'$kotlinDeps + compileOnly("com.google.code.findbugs:jsr305:3.0.2") + implementation("com.google.code.gson:gson:2.10.1")$kotlinDeps } java { toolchain { - languageVersion = JavaLanguageVersion.of(25) + languageVersion.set(JavaLanguageVersion.of(25)) } } - shadowJar { - archiveClassifier.set('') + tasks.shadowJar { + archiveClassifier.set("") // Exclude server classes from the final JAR dependencies { - exclude(dependency { it.moduleGroup == 'com.hypixel' }) + exclude(dependency("com.hypixel:.*:.*")) } } // Disable the default jar task to avoid conflicts with shadowJar - tasks.named('jar') { + tasks.named("jar") { enabled = false } - tasks.named('build') { - dependsOn shadowJar + tasks.named("build") { + dependsOn(tasks.shadowJar) } // Deploy plugin JAR to server mods folder - tasks.register('deployToServer', Copy) { + tasks.register("deployToServer") { // Using 'from shadowJar' automatically adds task dependency and proper input tracking - from shadowJar - into 'server/mods' + from(tasks.shadowJar) + into("server/mods") doLast { - println "Deployed to server/mods/" + println("Deployed to server/mods/") } } // Watch for changes and auto-rebuild (useful during development) - tasks.register('watch') { + tasks.register("watch") { doLast { - println "Watching for changes... Press Ctrl+C to stop." - println "Run 'gradle build --continuous' for auto-rebuild on file changes." + println("Watching for changes... Press Ctrl+C to stop.") + println("Run 'gradle build --continuous' for auto-rebuild on file changes.") } } """.trimIndent()) } private fun generateSettingsGradle(basePath: String) { - File(basePath, "settings.gradle").writeText("rootProject.name = '$modId'") + File(basePath, "settings.gradle.kts").writeText("rootProject.name = \"$modId\"") } private fun generateManifestEmpty(basePath: String) { From d1b08a26b67b244a6ebae501f96740e57f0e9428 Mon Sep 17 00:00:00 2001 From: Maarten Hanssen Date: Tue, 31 Mar 2026 15:48:16 +0200 Subject: [PATCH 2/7] Feat: added hotreload for the resource files --- build.gradle.kts | 1 + gradlew | 0 .../intellij/hotReload/HotReloadListener.kt | 64 ++++++++++ .../intellij/run/HytaleServerRunState.kt | 41 +++++-- src/main/resources/META-INF/plugin.xml | 14 ++- .../kotlin/hotReload/HotReloadListenerTest.kt | 110 ++++++++++++++++++ 6 files changed, 220 insertions(+), 10 deletions(-) mode change 100644 => 100755 gradlew create mode 100644 src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt create mode 100644 src/test/kotlin/hotReload/HotReloadListenerTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 34e1736..8dc3a29 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { // Configure IntelliJ Platform Gradle Plugin - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-extension.html intellijPlatform { + autoReload = true pluginConfiguration { name = providers.gradleProperty("pluginName") version = providers.gradleProperty("pluginVersion") diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt new file mode 100644 index 0000000..c4b8b55 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt @@ -0,0 +1,64 @@ +package com.hytaledocs.intellij.hotReload + +import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.rt.coverage.util.ErrorReporter.printInfo +import java.io.File + +class HotReloadListener( + private val project: Project, +) : BulkFileListener { + + private val projectBasePath: String? get() = project.basePath + + override fun after(events: List) { + val basePath = projectBasePath ?: return + + for (event in events) { + val file = event.file ?: continue + val path = file.path + + if (event.requestor == this) continue + + if (path.contains("src/main/resources")) { + val serverFilePath = path.substringAfter("src/main/resources/") + + syncFile(File(path), File("$basePath/server/mods/test/$serverFilePath")) + } + + if (path.contains("server/mods/test/")) { + val resourceFilePath = path.substringAfter("server/mods/test/") + + syncFile(File(path), File("$basePath/src/main/resources/$resourceFilePath")) + } + + if (path.contains("src/main/java") || path.contains("src/main/kotlin")) { + printInfo("java changed") + } + + printInfo("We are getting closer") + } + } + + private fun syncFile(source: File, target: File) { + try { + if (!source.exists()) return + + if (!target.parentFile.exists()) { + target.mkdirs() + } + + val overwrite = target.exists() + source.copyTo(target, overwrite = overwrite) + + LocalFileSystem.getInstance().refreshAndFindFileByIoFile(target) + } catch (e: Exception) { + com.intellij.openapi.diagnostic.Logger.getInstance(HotReloadListener::class.java).error(e) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt index 7a70b27..ecd0cba 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt @@ -1,7 +1,9 @@ package com.hytaledocs.intellij.run +import com.hytaledocs.intellij.hotReload.HotReloadListener import com.hytaledocs.intellij.services.JavaInstallService import com.hytaledocs.intellij.services.ServerLaunchService +import com.intellij.codeInsight.codeVision.CodeVisionState.NotReady.result import com.intellij.execution.DefaultExecutionResult import com.intellij.execution.ExecutionResult import com.intellij.execution.Executor @@ -16,12 +18,19 @@ import com.intellij.execution.runners.ProgramRunner import com.intellij.execution.ui.ConsoleView 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 import java.io.OutputStream import java.net.ServerSocket import java.nio.file.Files import java.nio.file.Path import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit +import kotlin.io.path.Path +import kotlin.io.path.copyTo +import kotlin.io.path.moveTo + /** * Execution state for Hytale Server run configuration. @@ -102,6 +111,7 @@ class HytaleServerProcessHandler( } private val launchService = ServerLaunchService.getInstance(project) + @Volatile private var isTerminating = false @@ -129,6 +139,16 @@ class HytaleServerProcessHandler( val serverAlreadyRunning = launchService.isServerRunning() val useHotReload = config.hotReloadEnabled && serverAlreadyRunning + if (config.hotReloadEnabled) { + printInfo("HotReload enabled") + + project.getMessageBus().connect().subscribe( + topic = VirtualFileManager.VFS_CHANGES, + handler = HotReloadListener(project) + ) + + } + if (useHotReload) { printInfo("=== Hot Reload Mode ===") printInfo("Server is already running - will rebuild and redeploy without restart") @@ -205,18 +225,22 @@ class HytaleServerProcessHandler( printInfo("Using Gradle wrapper: ${config.buildTask}") listOf(gradleWrapper, config.buildTask, "--no-daemon") to projectBasePath } + mavenWrapper != null -> { printInfo("Using Maven wrapper: ${config.buildTask}") listOf(mavenWrapper, config.buildTask) to projectBasePath } + globalGradle != null && hasGradleBuildFile(projectBasePath) -> { printInfo("Using global Gradle: ${config.buildTask}") listOf(globalGradle, config.buildTask, "--no-daemon") to projectBasePath } + globalMaven != null && hasMavenBuildFile(projectBasePath) -> { printInfo("Using global Maven: ${config.buildTask}") listOf(globalMaven, config.buildTask) to projectBasePath } + else -> { printError("No Gradle or Maven found (wrapper or global)") printInfo("Tip: Add gradlew.bat/gradlew to your project or install Gradle globally") @@ -641,16 +665,19 @@ class HytaleServerProcessHandler( printInfo("Debugger can connect on port $debugPort") } } + ServerLaunchService.ServerStatus.STOPPED -> { if (!isTerminating) { notifyProcessTerminated(0) } } + ServerLaunchService.ServerStatus.ERROR -> { if (!isTerminating) { notifyProcessTerminated(1) } } + else -> {} } } @@ -713,13 +740,13 @@ class HytaleServerProcessHandler( notifyProcessTerminated(1) false }.orTimeout(45, TimeUnit.SECONDS) - .exceptionally { e -> - // Timeout occurred - force kill and notify - LOG.warn("Stop server timed out, forcing termination", e) - printError("Server stop timed out - forcing shutdown") - notifyProcessTerminated(1) - false - } + .exceptionally { e -> + // Timeout occurred - force kill and notify + LOG.warn("Stop server timed out, forcing termination", e) + printError("Server stop timed out - forcing shutdown") + notifyProcessTerminated(1) + false + } } else { notifyProcessTerminated(0) } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 93000ef..98d632a 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -247,7 +247,6 @@ implementation="com.hytaledocs.intellij.uifile.highlighter.UIColorSettingsPage"/> - - + + + + + + + + diff --git a/src/test/kotlin/hotReload/HotReloadListenerTest.kt b/src/test/kotlin/hotReload/HotReloadListenerTest.kt new file mode 100644 index 0000000..2d0e685 --- /dev/null +++ b/src/test/kotlin/hotReload/HotReloadListenerTest.kt @@ -0,0 +1,110 @@ +package hotReload + +import com.hytaledocs.intellij.hotReload.HotReloadListener +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.testFramework.VfsTestUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +class HotReloadListenerTest: BasePlatformTestCase() { + + fun testHotReload() { + // 1. Manually register your listener for the test + val listener = HotReloadListener(project) + project.messageBus.connect(testRootDisposable).subscribe(VirtualFileManager.VFS_CHANGES, listener) + + // 2. Create a "source" file in the mock project + // Use a path that includes "src/main/resources" + val relPath = "src/main/resources/config.json" + + // Ensure the file is actually on disk so nio.Path can see it + val basePath = myFixture.project.basePath!! + val sourceFile = Path(basePath, relPath) + sourceFile.parent.toFile().mkdirs() + sourceFile.toFile().writeText("{ 'key': 'value' }") + + // Refresh VFS so IntelliJ knows about it + val virtualFile = VfsTestUtil.findFileByCaseSensitivePath(sourceFile.toString())!! + + // 3. Simulate a VFS Change Event + VfsTestUtil.createFile(virtualFile.parent, "config.json", "{ 'key': 'new_value' }") + + // 4. Assert the file exists in the "server" folder + val expectedServerFile = Path(basePath, "server/mods/test/config.json") + + assertTrue("File should have been synced to server folder: $expectedServerFile", expectedServerFile.exists()) + assertEquals("{ 'key': 'new_value' }", expectedServerFile.readText()) + } + + fun testReverseHotReload() { + val listener = HotReloadListener(project) + project.messageBus.connect(testRootDisposable).subscribe(VirtualFileManager.VFS_CHANGES, listener) + + val basePath = myFixture.project.basePath!! + val relPath = "server/mods/test/mod-config.json" + val sourceFile = Path(basePath, relPath) + sourceFile.parent.toFile().mkdirs() + sourceFile.toFile().writeText("{ 'mod': 'data' }") + + val virtualFile = VfsTestUtil.findFileByCaseSensitivePath(sourceFile.toString())!! + VfsTestUtil.createFile(virtualFile.parent, "mod-config.json", "{ 'mod': 'updated_data' }") + + val expectedResourceFile = Path(basePath, "src/main/resources/mod-config.json") + assertTrue("File should have been synced back to resources folder: $expectedResourceFile", expectedResourceFile.exists()) + assertEquals("{ 'mod': 'updated_data' }", expectedResourceFile.readText()) + } + + fun testDeepDirectorySync() { + val listener = HotReloadListener(project) + project.messageBus.connect(testRootDisposable).subscribe(VirtualFileManager.VFS_CHANGES, listener) + + val basePath = myFixture.project.basePath!! + + // Scenario: src/main/resources/common/Blocks/testblock exists, but server/mods/test/common/Blocks/testblock does not. + val relPath = "src/main/resources/common/Blocks/testblock/block.json" + val sourceFile = Path(basePath, relPath) + sourceFile.parent.toFile().mkdirs() + sourceFile.toFile().writeText("{ 'id': 'test' }") + + // Ensure server/mods exists, but subfolders don't + Path(basePath, "server/mods").toFile().mkdirs() + + // Refresh VFS + val virtualFile = VfsTestUtil.findFileByCaseSensitivePath(sourceFile.toString())!! + + // Simulate change + VfsTestUtil.createFile(virtualFile.parent, "block.json", "{ 'id': 'updated' }") + + val expectedServerFile = Path(basePath, "server/mods/test/common/Blocks/testblock/block.json") + assertTrue("Deep directory structure should have been created and file synced to: $expectedServerFile", expectedServerFile.exists()) + assertEquals("{ 'id': 'updated' }", expectedServerFile.readText()) + } + + fun testDeepDirectoryReverseSync() { + val listener = HotReloadListener(project) + project.messageBus.connect(testRootDisposable).subscribe(VirtualFileManager.VFS_CHANGES, listener) + + val basePath = myFixture.project.basePath!! + + // Scenario: server/mods/test/common/Blocks/testblock exists, but src/main/resources/common/Blocks/testblock does not. + val relPath = "server/mods/test/common/Blocks/testblock/block.json" + val sourceFile = Path(basePath, relPath) + sourceFile.parent.toFile().mkdirs() + sourceFile.toFile().writeText("{ 'id': 'server' }") + + // Ensure src/main/resources exists, but subfolders don't + Path(basePath, "src/main/resources").toFile().mkdirs() + + // Refresh VFS + val virtualFile = VfsTestUtil.findFileByCaseSensitivePath(sourceFile.toString())!! + + // Simulate change + VfsTestUtil.createFile(virtualFile.parent, "block.json", "{ 'id': 'server_updated' }") + + val expectedResourceFile = Path(basePath, "src/main/resources/common/Blocks/testblock/block.json") + assertTrue("Deep directory structure should have been created and file synced back to: $expectedResourceFile", expectedResourceFile.exists()) + assertEquals("{ 'id': 'server_updated' }", expectedResourceFile.readText()) + } +} \ No newline at end of file From f06a0b7460f6e63874fb39198209e6f2c1674fe6 Mon Sep 17 00:00:00 2001 From: Maarten Hanssen Date: Tue, 31 Mar 2026 22:11:23 +0200 Subject: [PATCH 3/7] Refactor: split up functionality of the hot reload Also updated the reload to also check for deleted files --- .../hotReload/FileChangeClassifier.kt | 9 + .../intellij/hotReload/FileChangeType.kt | 15 + .../intellij/hotReload/FileSynchronizer.kt | 8 + .../intellij/hotReload/HotReloadListener.kt | 57 +- .../hotReload/HytaleFileChangeClassifier.kt | 67 ++ .../hotReload/IntellijFileSynchronizer.kt | 49 ++ .../intellij/run/HytaleBuildService.kt | 64 ++ .../hytaledocs/intellij/run/HytaleConsole.kt | 8 + .../intellij/run/HytaleDeploymentService.kt | 152 ++++ .../intellij/run/HytalePathResolver.kt | 155 ++++ .../intellij/run/HytaleProcessManager.kt | 28 + .../run/HytaleServerProcessHandler.kt | 310 ++++++++ .../intellij/run/HytaleServerRunState.kt | 704 +----------------- .../hotReload/HotReloadListenerTest.kt | 3 +- 14 files changed, 888 insertions(+), 741 deletions(-) create mode 100644 src/main/kotlin/com/hytaledocs/intellij/hotReload/FileChangeClassifier.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/hotReload/FileChangeType.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/hotReload/FileSynchronizer.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifier.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/hotReload/IntellijFileSynchronizer.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/run/HytaleBuildService.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/run/HytaleConsole.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/run/HytaleDeploymentService.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/run/HytalePathResolver.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/run/HytaleProcessManager.kt create mode 100644 src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt rename src/test/kotlin/{ => com/hytaledocs/intellij}/hotReload/HotReloadListenerTest.kt (98%) diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/FileChangeClassifier.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/FileChangeClassifier.kt new file mode 100644 index 0000000..b7c15cb --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/FileChangeClassifier.kt @@ -0,0 +1,9 @@ +package com.hytaledocs.intellij.hotReload + +interface FileChangeClassifier { + fun classify( + absolutePath: String, + projectBasePath: String, + isDeleted: Boolean, + ): FileChangeType? +} \ No newline at end of file diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/FileChangeType.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/FileChangeType.kt new file mode 100644 index 0000000..6600e9c --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/FileChangeType.kt @@ -0,0 +1,15 @@ +package com.hytaledocs.intellij.hotReload + +sealed class FileChangeType { + + data class SyncWrite( + val absoluteSourcePath: String, + val absoluteTargetPath: String, + ) : FileChangeType() + + data class SyncDelete( + val absoluteTargetPath: String, + ) : FileChangeType() + + data object SourceCodeChanged : FileChangeType() +} \ No newline at end of file diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/FileSynchronizer.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/FileSynchronizer.kt new file mode 100644 index 0000000..890a05c --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/FileSynchronizer.kt @@ -0,0 +1,8 @@ +package com.hytaledocs.intellij.hotReload + +import java.io.File + +interface FileSynchronizer { + fun sync(source: File, target: File) + fun delete(target: File) +} \ No newline at end of file diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt index c4b8b55..9dce463 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt @@ -1,63 +1,48 @@ package com.hytaledocs.intellij.hotReload -import com.intellij.openapi.application.WriteAction import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.LocalFileSystem -import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent import com.intellij.openapi.vfs.newvfs.events.VFileEvent -import com.intellij.rt.coverage.util.ErrorReporter.printInfo import java.io.File class HotReloadListener( private val project: Project, + private val classifier: FileChangeClassifier, + private val synchronizer: FileSynchronizer, + private val recentlySyncedPath: MutableSet, ) : BulkFileListener { - private val projectBasePath: String? get() = project.basePath override fun after(events: List) { - val basePath = projectBasePath ?: return + val basePath = project.basePath ?: return + var sourceCodeChanges = false for (event in events) { - val file = event.file ?: continue - val path = file.path + val path = event.path + val canonicalPath = File(path).canonicalPath - if (event.requestor == this) continue + if (recentlySyncedPath.remove(canonicalPath)) continue - if (path.contains("src/main/resources")) { - val serverFilePath = path.substringAfter("src/main/resources/") + val isDelete = event is VFileDeleteEvent - syncFile(File(path), File("$basePath/server/mods/test/$serverFilePath")) - } + when (val change = classifier.classify(path, basePath, isDelete)) { + is FileChangeType.SyncWrite -> synchronizer.sync( + File(change.absoluteSourcePath), + File(change.absoluteTargetPath) + ) - if (path.contains("server/mods/test/")) { - val resourceFilePath = path.substringAfter("server/mods/test/") + is FileChangeType.SyncDelete -> synchronizer.delete(File(change.absoluteTargetPath)) - syncFile(File(path), File("$basePath/src/main/resources/$resourceFilePath")) - } + FileChangeType.SourceCodeChanged -> sourceCodeChanges = true - if (path.contains("src/main/java") || path.contains("src/main/kotlin")) { - printInfo("java changed") + null -> Unit } - - printInfo("We are getting closer") } - } - - private fun syncFile(source: File, target: File) { - try { - if (!source.exists()) return - - if (!target.parentFile.exists()) { - target.mkdirs() - } - - val overwrite = target.exists() - source.copyTo(target, overwrite = overwrite) - LocalFileSystem.getInstance().refreshAndFindFileByIoFile(target) - } catch (e: Exception) { - com.intellij.openapi.diagnostic.Logger.getInstance(HotReloadListener::class.java).error(e) + if (sourceCodeChanges) { + println("update") + sourceCodeChanges = false } } diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifier.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifier.kt new file mode 100644 index 0000000..a9d4354 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifier.kt @@ -0,0 +1,67 @@ +package com.hytaledocs.intellij.hotReload + +import java.io.File + +class HytaleFileChangeClassifier : FileChangeClassifier { + override fun classify( + absolutePath: String, + projectBasePath: String, + isDeleted: Boolean + ): FileChangeType? { + val path = absolutePath.replace(File.separatorChar, '/') + val base = projectBasePath.replace(File.separatorChar, '/') + + if (!path.startsWith(base)) return null + + return when { + path.containsSegment(RESOURCES_SEGMENT) -> + syncOrDelete( + path = path, + isDelete = isDeleted, + targetPath = { relative -> "$base/$SERVER_MODS_SEGMENT/$relative" }, + segmentAfter = RESOURCES_SEGMENT, + ) + + path.containsSegment(SERVER_MODS_SEGMENT) -> + syncOrDelete( + path = path, + isDelete = isDeleted, + targetPath = { relative -> "$base/$RESOURCES_SEGMENT/$relative" }, + segmentAfter = SERVER_MODS_SEGMENT, + ) + + + SOURCE_SEGMENTS.any { path.containsSegment(it) } -> + FileChangeType.SourceCodeChanged + + else -> null + } + + } + + + private fun syncOrDelete( + path: String, + isDelete: Boolean, + targetPath: (relative: String) -> String, + segmentAfter: String, + ): FileChangeType { + val relative = path.substringAfter("$segmentAfter/") + val target = targetPath(relative) + return if (isDelete) { + FileChangeType.SyncDelete(absoluteTargetPath = target) + } else { + FileChangeType.SyncWrite(absoluteSourcePath = path, absoluteTargetPath = target) + } + } + + private fun String.containsSegment(segment: String): Boolean = + contains("/$segment/") || contains("/$segment") + + + private companion object { + const val RESOURCES_SEGMENT = "src/main/resources" + const val SERVER_MODS_SEGMENT = "server/mods/test" + val SOURCE_SEGMENTS = listOf("src/main/java", "src/main/kotlin") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/IntellijFileSynchronizer.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/IntellijFileSynchronizer.kt new file mode 100644 index 0000000..c7b5124 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/IntellijFileSynchronizer.kt @@ -0,0 +1,49 @@ +package com.hytaledocs.intellij.hotReload + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.vfs.LocalFileSystem +import java.io.File + +class IntellijFileSynchronizer( + private val recentlySyncedPath: MutableSet, +) : FileSynchronizer { + + private val logger: Logger = Logger.getInstance(IntellijFileSynchronizer::class.java) + + override fun sync(source: File, target: File) { + if (!source.exists()) { + logger.warn("Skipping sync: source does not exist at ${source.path}") + return + } + + if (target.exists() && source.readBytes().contentEquals(target.readBytes())) return + + try { + target.parentFile.mkdirs() + + recentlySyncedPath.add(target.canonicalPath) + source.copyTo(target, overwrite = true) + LocalFileSystem.getInstance().refreshAndFindFileByIoFile(target) + + logger.info("Successfully synced ${source.path} -> ${target.path}") + } catch (e: Exception) { + recentlySyncedPath.remove(target.canonicalPath) + logger.error("Failed to sync ${source.path}", e) + } + } + + override fun delete(target: File) { + if (!target.exists()) return + + try { + recentlySyncedPath.add(target.canonicalPath) + target.delete() + LocalFileSystem.getInstance().refresh(true) + + logger.info("Successfully deleted ${target.path}") + } catch (e: Exception) { + recentlySyncedPath.remove(target.canonicalPath) + logger.error("Failed to delete ${target.path}", e) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleBuildService.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleBuildService.kt new file mode 100644 index 0000000..60aa18e --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleBuildService.kt @@ -0,0 +1,64 @@ +package com.hytaledocs.intellij.run + +import java.nio.file.Path + +class HytaleBuildService( + private val pathResolver: HytalePathResolver, + private val config: HytaleServerRunConfiguration, + private val console: HytaleConsole +) { + fun executeBuild(projectBasePath: String): Boolean { + val gradleWrapper = pathResolver.findGradleWrapper(projectBasePath) + val mavenWrapper = pathResolver.findMavenWrapper(projectBasePath) + val globalGradle = pathResolver.findGlobalGradle() + val globalMaven = pathResolver.findGlobalMaven() + + val (command, workDir) = when { + gradleWrapper != null -> { + console.printInfo("Using Gradle wrapper: ${config.buildTask}") + listOf(gradleWrapper, config.buildTask, "--no-daemon") to projectBasePath + } + + mavenWrapper != null -> { + console.printInfo("Using Maven wrapper: ${config.buildTask}") + listOf(mavenWrapper, config.buildTask) to projectBasePath + } + + globalGradle != null && pathResolver.hasGradleBuildFile(projectBasePath) -> { + console.printInfo("Using global Gradle: ${config.buildTask}") + listOf(globalGradle, config.buildTask, "--no-daemon") to projectBasePath + } + + globalMaven != null && pathResolver.hasMavenBuildFile(projectBasePath) -> { + console.printInfo("Using global Maven: ${config.buildTask}") + listOf(globalMaven, config.buildTask) to projectBasePath + } + + else -> { + console.printError("No Gradle or Maven found (wrapper or global)") + console.printInfo("Tip: Add gradlew.bat/gradlew to your project or install Gradle globally") + return false + } + } + + return try { + val process = ProcessBuilder(command) + .directory(Path.of(workDir).toFile()) + .redirectErrorStream(true) + .start() + + // Read build output + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + console.println(line) + } + } + + val exitCode = process.waitFor() + exitCode == 0 + } catch (e: Exception) { + console.printError("Build error: ${e.message}") + false + } + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleConsole.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleConsole.kt new file mode 100644 index 0000000..86b1d70 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleConsole.kt @@ -0,0 +1,8 @@ +package com.hytaledocs.intellij.run + +interface HytaleConsole { + fun println(text: String) + fun printInfo(text: String) + fun printSuccess(text: String) + fun printError(text: String) +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleDeploymentService.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleDeploymentService.kt new file mode 100644 index 0000000..e7ee0b1 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleDeploymentService.kt @@ -0,0 +1,152 @@ +package com.hytaledocs.intellij.run + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.copyTo +import kotlin.io.path.moveTo + +class HytaleDeploymentService( + private val pathResolver: HytalePathResolver, + private val console: HytaleConsole, + private val config: HytaleServerRunConfiguration +) { + companion object { + private val LOG = com.intellij.openapi.diagnostic.Logger.getInstance(HytaleDeploymentService::class.java) + } + + fun deployPlugin(projectBasePath: String): Boolean { + val jarPath = pathResolver.resolvePluginJarPath(projectBasePath) ?: run { + console.printError("Plugin JAR not found: ${config.pluginJarPath}") + return false + } + + val serverPath = pathResolver.resolveServerPath(projectBasePath) + val modsDir = serverPath.resolve("mods") + + try { + // Create mods directory if needed + if (!Files.exists(modsDir)) { + Files.createDirectories(modsDir) + console.printInfo("Created mods directory") + } + } catch (e: Exception) { + console.printError("Failed to create mods directory: ${e.message}") + return false + } + + val baseJarName = jarPath.fileName.toString().substringBeforeLast(".jar") + val devFileName = "${baseJarName}-dev.jar" + val targetPath = modsDir.resolve(devFileName) + + val maxRetries = 3 + var lastException: Exception? = null + + for (attempt in 1..maxRetries) { + try { + LOG.info("Deploy attempt $attempt/$maxRetries for: $devFileName") + console.printInfo("Deploy attempt $attempt/$maxRetries...") + + // Step 1: Create shadow copy in temp location + val tempFile = Files.createTempFile("hytale-deploy-", ".jar") + try { + Files.copy(jarPath, tempFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + LOG.debug("Created shadow copy at: $tempFile") + + // Step 2: Try atomic move to target + try { + Files.move( + tempFile, + targetPath, + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + java.nio.file.StandardCopyOption.REPLACE_EXISTING + ) + console.printInfo("Deployed ${devFileName} to ${modsDir}") + LOG.info("Atomic move succeeded to: $targetPath") + + // Success! Clean up old timestamped JARs + cleanupOldTimestampedJars(modsDir, baseJarName) + + return true + } catch (atomicEx: Exception) { + LOG.debug("Atomic move failed (${atomicEx.message}), trying non-atomic approach") + + // Step 3: Atomic move failed - try regular move/copy + try { + // Try to delete existing file first + Files.deleteIfExists(targetPath) + Files.move(tempFile, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + console.printInfo("Deployed ${devFileName} to ${modsDir}") + LOG.info("Non-atomic move succeeded to: $targetPath") + + cleanupOldTimestampedJars(modsDir, baseJarName) + return true + } catch (moveEx: Exception) { + LOG.debug("Non-atomic move failed (${moveEx.message}), falling back to timestamped filename") + + // Step 4: File is locked - use timestamped filename as fallback + val timestamp = System.currentTimeMillis() + val timestampedFileName = "${baseJarName}-dev-${timestamp}.jar" + val timestampedPath = modsDir.resolve(timestampedFileName) + + Files.copy(jarPath, timestampedPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING) + console.printInfo("Deployed ${timestampedFileName} to ${modsDir} (timestamped fallback)") + LOG.info("Timestamped fallback succeeded: $timestampedPath") + + cleanupOldTimestampedJars(modsDir, baseJarName) + return true + } + } + } finally { + Files.deleteIfExists(tempFile) + } + } catch (e: Exception) { + lastException = e + LOG.warn("Deploy attempt $attempt failed: ${e.message}") + if (attempt < maxRetries) { + Thread.sleep(if (attempt == 1) 1000L else 2000L) + } + } + } + + console.printError("Deployment failed after $maxRetries attempts: ${lastException?.message}") + return false + } + + private fun cleanupOldTimestampedJars(modsDir: Path, baseJarName: String) { + val maxOldJarsToKeep = 2 + try { + val pattern = Regex("${Regex.escape(baseJarName)}-dev(-\\d+)?\\.jar") + + val devJars = Files.list(modsDir).use { stream -> + stream + .filter { path -> + val fileName = path.fileName.toString() + pattern.matches(fileName) + } + .sorted { a, b -> + // Sort by modification time, newest first + Files.getLastModifiedTime(b).compareTo(Files.getLastModifiedTime(a)) + } + .toList() + } + + // Keep only the most recent JARs + if (devJars.size > maxOldJarsToKeep) { + val toDelete = devJars.drop(maxOldJarsToKeep) + for (jar in toDelete) { + try { + Files.deleteIfExists(jar) + console.printInfo("Cleaned up old dev JAR: ${jar.fileName}") + LOG.info("Cleaned up old dev JAR: ${jar.fileName}") + } catch (e: Exception) { + // File might still be locked by server, ignore + LOG.debug("Could not delete old JAR (may be in use): ${jar.fileName}") + } + } + } + } catch (e: Exception) { + LOG.warn("Failed to cleanup old timestamped JARs", e) + // Non-fatal, continue execution + } + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytalePathResolver.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytalePathResolver.kt new file mode 100644 index 0000000..1e4da53 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytalePathResolver.kt @@ -0,0 +1,155 @@ +package com.hytaledocs.intellij.run + +import com.hytaledocs.intellij.services.JavaInstallService +import com.intellij.openapi.diagnostic.Logger +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.Path + +class HytalePathResolver( + private val config: HytaleServerRunConfiguration +) { + companion object { + private val LOG = Logger.getInstance(HytalePathResolver::class.java) + } + + fun resolveServerPath(projectBasePath: String): Path { + val serverPath = config.serverPath + return if (Path.of(serverPath).isAbsolute) { + Path.of(serverPath) + } else { + Path.of(projectBasePath, serverPath) + } + } + + fun resolveJavaPath(): Path? { + // Use configured path if available + if (config.javaPath.isNotBlank()) { + val path = Path.of(config.javaPath) + if (Files.exists(path)) return path + } + + // Find Java 25+ + val javaService = JavaInstallService.getInstance() + val java25 = javaService.findJava25() ?: return null + return javaService.getJavaExecutable(java25) + } + + fun findGradleWrapper(projectBasePath: String): String? { + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val wrapperName = if (isWindows) "gradlew.bat" else "gradlew" + val wrapper = Path.of(projectBasePath, wrapperName) + if (!Files.exists(wrapper)) return null + + // On Unix, ensure the wrapper is executable + if (!isWindows) { + makeExecutableIfNeeded(wrapper) + } + return wrapper.toString() + } + + fun findMavenWrapper(projectBasePath: String): String? { + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val wrapperName = if (isWindows) "mvnw.cmd" else "mvnw" + val wrapper = Path.of(projectBasePath, wrapperName) + if (!Files.exists(wrapper)) return null + + // On Unix, ensure the wrapper is executable + if (!isWindows) { + makeExecutableIfNeeded(wrapper) + } + return wrapper.toString() + } + + fun findGlobalGradle(): String? = findGlobalTool("gradle") + + fun findGlobalMaven(): String? = findGlobalTool("mvn") + + fun hasGradleBuildFile(projectBasePath: String): Boolean { + return Files.exists(Path.of(projectBasePath, "build.gradle")) || + Files.exists(Path.of(projectBasePath, "build.gradle.kts")) + } + + fun hasMavenBuildFile(projectBasePath: String): Boolean { + return Files.exists(Path.of(projectBasePath, "pom.xml")) + } + + fun resolvePluginJarPath(projectBasePath: String): Path? { + val jarPathStr = config.pluginJarPath + if (jarPathStr.isBlank()) return null + + // Try relative path first + val relativePath = Path.of(projectBasePath, jarPathStr) + if (Files.exists(relativePath)) return relativePath + + // Try absolute path + val absolutePath = Path.of(jarPathStr) + if (Files.exists(absolutePath)) return absolutePath + + // Try common build output locations + val commonLocations = listOf( + "build/libs/${jarPathStr}", + "target/${jarPathStr}", + "build/libs/${Path.of(jarPathStr).fileName}", + "target/${Path.of(jarPathStr).fileName}" + ) + + for (location in commonLocations) { + val path = Path.of(projectBasePath, location) + if (Files.exists(path)) return path + } + + // Search in build/libs for any JAR matching pattern + val buildLibs = Path.of(projectBasePath, "build/libs") + if (Files.exists(buildLibs)) { + Files.list(buildLibs).use { stream -> + val jar = stream + .filter { it.toString().endsWith(".jar") } + .filter { !it.toString().contains("-sources") && !it.toString().contains("-javadoc") } + .findFirst() + .orElse(null) + if (jar != null) { + return jar + } + } + } + + return null + } + + private fun findGlobalTool(name: String): String? { + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + return try { + val command = if (isWindows) listOf("where", name) else listOf("which", name) + val process = ProcessBuilder(command).start() + val output = process.inputStream.bufferedReader().readLines() + process.waitFor(2, TimeUnit.SECONDS) + + if (isWindows) { + // On Windows, pick the best executable from multiple results + output.firstOrNull { it.endsWith(".exe") } + ?: output.firstOrNull { it.endsWith(".cmd") } + ?: output.firstOrNull { it.endsWith(".bat") } + ?: output.firstOrNull() + } else { + output.firstOrNull() + } + } catch (e: Exception) { + null + } + } + + private fun makeExecutableIfNeeded(file: Path) { + try { + if (!Files.isExecutable(file)) { + LOG.info("Making ${file.fileName} executable") + val process = ProcessBuilder("chmod", "+x", file.toString()) + .start() + process.waitFor(5, TimeUnit.SECONDS) + } + } catch (e: Exception) { + LOG.warn("Failed to make ${file.fileName} executable: ${e.message}") + } + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleProcessManager.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleProcessManager.kt new file mode 100644 index 0000000..0a5b83d --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleProcessManager.kt @@ -0,0 +1,28 @@ +package com.hytaledocs.intellij.run + +import com.intellij.openapi.diagnostic.Logger +import java.util.concurrent.TimeUnit + +class HytaleProcessManager { + companion object { + private val LOG = Logger.getInstance(HytaleProcessManager::class.java) + } + + fun killLingeringServerProcesses(console: HytaleConsole) { + try { + ProcessHandle.allProcesses() + .filter { handle -> + handle.info().commandLine() + .map { cmd -> cmd.contains("HytaleServer.jar") } + .orElse(false) + } + .forEach { handle -> + console.printInfo("Killing lingering server process: ${handle.pid()}") + handle.destroyForcibly() + } + Thread.sleep(2000) // Wait for processes to terminate + } catch (e: Exception) { + LOG.warn("Failed to kill lingering processes", e) + } + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt new file mode 100644 index 0000000..4f05aa9 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt @@ -0,0 +1,310 @@ +package com.hytaledocs.intellij.run + +import com.hytaledocs.intellij.hotReload.HotReloadListener +import com.hytaledocs.intellij.hotReload.HytaleFileChangeClassifier +import com.hytaledocs.intellij.hotReload.IntellijFileSynchronizer +import com.hytaledocs.intellij.services.ServerLaunchService +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessOutputType +import com.intellij.execution.ui.ConsoleView +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFileManager +import java.io.OutputStream +import java.util.Collections +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +/** + * Process handler for the Hytale server. + * Manages the entire lifecycle: build, deploy, start, and hot reload. + * Supports debug mode with JDWP agent for remote debugging. + */ +class HytaleServerProcessHandler( + private val project: Project, + private val config: HytaleServerRunConfiguration, + private val console: ConsoleView, + private val isDebugMode: Boolean = false, + private val debugPort: Int = 5005 +) : ProcessHandler(), HytaleConsole { + + companion object { + private val LOG = Logger.getInstance(HytaleServerProcessHandler::class.java) + } + + private val pathResolver = HytalePathResolver(config) + private val buildService = HytaleBuildService(pathResolver, config, this) + private val deploymentService = HytaleDeploymentService(pathResolver, this, config) + private val processManager = HytaleProcessManager() + private val launchService = ServerLaunchService.getInstance(project) + + @Volatile + private var isTerminating = false + + fun startExecution() { + startNotify() + + CompletableFuture.runAsync { + try { + execute() + } catch (e: Exception) { + printError("Execution failed: ${e.message}") + LOG.error("Hytale server execution failed", e) + notifyProcessTerminated(1) + } + } + } + + private fun execute() { + val projectBasePath = project.basePath ?: run { + printError("Project base path not found") + notifyProcessTerminated(1) + return + } + + val serverAlreadyRunning = launchService.isServerRunning() + val useHotReload = config.hotReloadEnabled && serverAlreadyRunning + + if (useHotReload) { + printInfo("=== Hot Reload Mode ===") + printInfo("Server is already running - will rebuild and redeploy without restart") + println("") + } + + // Step 1: Build (if enabled) + if (config.buildBeforeRun && config.buildTask.isNotBlank()) { + printInfo("=== Building plugin ===") + if (!buildService.executeBuild(projectBasePath)) { + printError("Build failed!") + notifyProcessTerminated(1) + return + } + printSuccess("Build completed successfully") + println("") + } + + // Step 2: Stop server if running (only if NOT using hot reload) + if (serverAlreadyRunning && !useHotReload) { + printInfo("=== Stopping server for redeploy ===") + launchService.stopServer { line -> println(line) } + .get(30, TimeUnit.SECONDS) + printSuccess("Server stopped") + // Wait a bit for file handles to be released + Thread.sleep(1000) + println("") + } + + // Step 3: Deploy plugin (if enabled) + if (config.deployPlugin && config.pluginJarPath.isNotBlank()) { + printInfo("=== Deploying plugin ===") + + // Kill any lingering HytaleServer processes that might be holding files + // (only if not using hot reload - we don't want to kill our running server!) + if (!useHotReload) { + killLingeringServerProcesses() + } + + if (!deploymentService.deployPlugin(projectBasePath)) { + printError("Deploy failed!") + // Continue anyway - server might still start + } else { + printSuccess("Plugin deployed successfully") + } + + println("") + } + + if (config.hotReloadEnabled) { + printInfo("HotReload enabled") + val recentlySyncedPath: MutableSet = Collections.synchronizedSet(mutableSetOf()) + + val listener = HotReloadListener( + project, + HytaleFileChangeClassifier(), + synchronizer = IntellijFileSynchronizer(recentlySyncedPath), + recentlySyncedPath + ) + + project.getMessageBus().connect().subscribe( + topic = VirtualFileManager.VFS_CHANGES, + handler = listener + ) + } + + // Step 4: Start server (only if NOT using hot reload) + if (useHotReload) { + printInfo("=== Hot Reload Complete ===") + printInfo("Plugin deployed - server will reload automatically") + printSuccess("Hot reload successful!") + // Don't call notifyProcessTerminated - keep the process handler alive + // to show logs and allow stopping later + } else { + printInfo("=== Starting Hytale Server ===") + if (isDebugMode) { + printInfo("Debug mode enabled on port $debugPort") + } + startServer(projectBasePath) + } + + + } + + private fun killLingeringServerProcesses() { + processManager.killLingeringServerProcesses(this) + } + + private fun startServer(projectBasePath: String) { + val serverPath = pathResolver.resolveServerPath(projectBasePath) + + // Validate server files + val validation = launchService.validateServerFiles(serverPath) + if (!validation.isValid) { + printError("Server validation failed:") + validation.errors.forEach { printError(" - $it") } + notifyProcessTerminated(1) + return + } + + // Find Java + val javaPath = pathResolver.resolveJavaPath() ?: run { + printError("Java 25+ not found. Please configure Java path.") + notifyProcessTerminated(1) + return + } + + // Build additional JVM args with debug support + val additionalJvmArgs = buildList { + // Add user-specified JVM args + addAll(config.jvmArgs.split(" ").filter { it.isNotBlank() }) + + // Add debug agent if in debug mode + if (isDebugMode) { + add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:$debugPort") + } + } + + // Build server config + val serverConfig = ServerLaunchService.ServerConfig( + serverPath = serverPath, + javaPath = javaPath, + minMemory = config.minMemory, + maxMemory = config.maxMemory, + port = config.port, + authMode = if (config.authMode == "authenticated") + ServerLaunchService.AuthMode.AUTHENTICATED + else + ServerLaunchService.AuthMode.OFFLINE, + allowOp = config.allowOp, + acceptEarlyPlugins = config.acceptEarlyPlugins, + additionalJvmArgs = additionalJvmArgs, + additionalServerArgs = config.serverArgs.split(" ").filter { it.isNotBlank() } + ) + + // Start server with callbacks + launchService.startServer( + config = serverConfig, + logCallback = { line -> println(line) }, + statusCallback = { status -> + when (status) { + ServerLaunchService.ServerStatus.RUNNING -> { + printSuccess("Server is now running!") + if (isDebugMode) { + printInfo("Debugger can connect on port $debugPort") + } + } + + ServerLaunchService.ServerStatus.STOPPED -> { + if (!isTerminating) { + notifyProcessTerminated(0) + } + } + + ServerLaunchService.ServerStatus.ERROR -> { + if (!isTerminating) { + notifyProcessTerminated(1) + } + } + + else -> {} + } + } + ) + } + + override fun println(text: String) { + notifyTextAvailable("$text\n", ProcessOutputType.STDOUT) + } + + override fun printInfo(text: String) { + notifyTextAvailable("[INFO] $text\n", ProcessOutputType.STDOUT) + } + + override fun printSuccess(text: String) { + notifyTextAvailable("[SUCCESS] $text\n", ProcessOutputType.STDOUT) + } + + override fun printError(text: String) { + notifyTextAvailable("[ERROR] $text\n", ProcessOutputType.STDERR) + } + + override fun destroyProcessImpl() { + isTerminating = true + + // Always stop the server when the stop button is pressed + if (launchService.isServerRunning()) { + printInfo("Stopping server...") + launchService.stopServer( + logCallback = { line -> + try { + println(line) + } catch (e: Exception) { + // Ignore - console may be closing + } + }, + statusCallback = { status -> + if (status == ServerLaunchService.ServerStatus.STOPPED) { + printInfo("Server stopped successfully") + notifyProcessTerminated(0) + } + } + ).exceptionally { e -> + LOG.warn("Error stopping server", e) + notifyProcessTerminated(1) + false + }.orTimeout(45, TimeUnit.SECONDS) + .exceptionally { e -> + LOG.warn("Stop server timed out, forcing termination", e) + printError("Server stop timed out - forcing shutdown") + notifyProcessTerminated(1) + false + } + } else { + notifyProcessTerminated(0) + } + } + + override fun detachProcessImpl() { + notifyProcessDetached() + } + + override fun detachIsDefault(): Boolean = false + + override fun getProcessInput(): OutputStream? { + return object : OutputStream() { + private val buffer = StringBuilder() + + override fun write(b: Int) { + val char = b.toChar() + if (char == '\n') { + val command = buffer.toString().trim() + if (command.isNotEmpty()) { + launchService.sendCommand(command) + } + buffer.clear() + } else { + buffer.append(char) + } + } + } + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt index ecd0cba..0116a70 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt @@ -1,9 +1,5 @@ package com.hytaledocs.intellij.run -import com.hytaledocs.intellij.hotReload.HotReloadListener -import com.hytaledocs.intellij.services.JavaInstallService -import com.hytaledocs.intellij.services.ServerLaunchService -import com.intellij.codeInsight.codeVision.CodeVisionState.NotReady.result import com.intellij.execution.DefaultExecutionResult import com.intellij.execution.ExecutionResult import com.intellij.execution.Executor @@ -11,25 +7,12 @@ import com.intellij.execution.configurations.RemoteConnection import com.intellij.execution.configurations.RunProfileState import com.intellij.execution.executors.DefaultDebugExecutor import com.intellij.execution.filters.TextConsoleBuilderFactory -import com.intellij.execution.process.ProcessHandler -import com.intellij.execution.process.ProcessOutputType import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.runners.ProgramRunner -import com.intellij.execution.ui.ConsoleView +import com.intellij.execution.process.ProcessHandler 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 -import java.io.OutputStream import java.net.ServerSocket -import java.nio.file.Files -import java.nio.file.Path -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit -import kotlin.io.path.Path -import kotlin.io.path.copyTo -import kotlin.io.path.moveTo /** @@ -93,688 +76,3 @@ class HytaleServerRunState( } } -/** - * Process handler for the Hytale server. - * Manages the entire lifecycle: build, deploy, start, and hot reload. - * Supports debug mode with JDWP agent for remote debugging. - */ -class HytaleServerProcessHandler( - private val project: Project, - private val config: HytaleServerRunConfiguration, - private val console: ConsoleView, - private val isDebugMode: Boolean = false, - private val debugPort: Int = 5005 -) : ProcessHandler() { - - companion object { - private val LOG = Logger.getInstance(HytaleServerProcessHandler::class.java) - } - - private val launchService = ServerLaunchService.getInstance(project) - - @Volatile - private var isTerminating = false - - fun startExecution() { - startNotify() - - CompletableFuture.runAsync { - try { - execute() - } catch (e: Exception) { - printError("Execution failed: ${e.message}") - LOG.error("Hytale server execution failed", e) - notifyProcessTerminated(1) - } - } - } - - private fun execute() { - val projectBasePath = project.basePath ?: run { - printError("Project base path not found") - notifyProcessTerminated(1) - return - } - - val serverAlreadyRunning = launchService.isServerRunning() - val useHotReload = config.hotReloadEnabled && serverAlreadyRunning - - if (config.hotReloadEnabled) { - printInfo("HotReload enabled") - - project.getMessageBus().connect().subscribe( - topic = VirtualFileManager.VFS_CHANGES, - handler = HotReloadListener(project) - ) - - } - - if (useHotReload) { - printInfo("=== Hot Reload Mode ===") - printInfo("Server is already running - will rebuild and redeploy without restart") - println("") - } - - // Step 1: Build (if enabled) - if (config.buildBeforeRun && config.buildTask.isNotBlank()) { - printInfo("=== Building plugin ===") - if (!executeBuild(projectBasePath)) { - printError("Build failed!") - notifyProcessTerminated(1) - return - } - printSuccess("Build completed successfully") - println("") - } - - // Step 2: Stop server if running (only if NOT using hot reload) - if (serverAlreadyRunning && !useHotReload) { - printInfo("=== Stopping server for redeploy ===") - launchService.stopServer { line -> println(line) } - .get(30, java.util.concurrent.TimeUnit.SECONDS) - printSuccess("Server stopped") - // Wait a bit for file handles to be released - Thread.sleep(1000) - println("") - } - - // Step 3: Deploy plugin (if enabled) - if (config.deployPlugin && config.pluginJarPath.isNotBlank()) { - printInfo("=== Deploying plugin ===") - - // Kill any lingering HytaleServer processes that might be holding files - // (only if not using hot reload - we don't want to kill our running server!) - if (!useHotReload) { - killLingeringServerProcesses() - } - - if (!deployPlugin(projectBasePath)) { - printError("Deploy failed!") - // Continue anyway - server might still start - } else { - printSuccess("Plugin deployed successfully") - } - - println("") - } - - // Step 4: Start server (only if NOT using hot reload) - if (useHotReload) { - printInfo("=== Hot Reload Complete ===") - printInfo("Plugin deployed - server will reload automatically") - printSuccess("Hot reload successful!") - // Don't call notifyProcessTerminated - keep the process handler alive - // to show logs and allow stopping later - } else { - printInfo("=== Starting Hytale Server ===") - if (isDebugMode) { - printInfo("Debug mode enabled on port $debugPort") - } - startServer(projectBasePath) - } - } - - private fun executeBuild(projectBasePath: String): Boolean { - val gradleWrapper = findGradleWrapper(projectBasePath) - val mavenWrapper = findMavenWrapper(projectBasePath) - val globalGradle = findGlobalGradle() - val globalMaven = findGlobalMaven() - - val (command, workDir) = when { - gradleWrapper != null -> { - printInfo("Using Gradle wrapper: ${config.buildTask}") - listOf(gradleWrapper, config.buildTask, "--no-daemon") to projectBasePath - } - - mavenWrapper != null -> { - printInfo("Using Maven wrapper: ${config.buildTask}") - listOf(mavenWrapper, config.buildTask) to projectBasePath - } - - globalGradle != null && hasGradleBuildFile(projectBasePath) -> { - printInfo("Using global Gradle: ${config.buildTask}") - listOf(globalGradle, config.buildTask, "--no-daemon") to projectBasePath - } - - globalMaven != null && hasMavenBuildFile(projectBasePath) -> { - printInfo("Using global Maven: ${config.buildTask}") - listOf(globalMaven, config.buildTask) to projectBasePath - } - - else -> { - printError("No Gradle or Maven found (wrapper or global)") - printInfo("Tip: Add gradlew.bat/gradlew to your project or install Gradle globally") - return false - } - } - - return try { - val process = ProcessBuilder(command) - .directory(Path.of(workDir).toFile()) - .redirectErrorStream(true) - .start() - - // Read build output - process.inputStream.bufferedReader().useLines { lines -> - lines.forEach { line -> - println(line) - } - } - - val exitCode = process.waitFor() - exitCode == 0 - } catch (e: Exception) { - printError("Build error: ${e.message}") - false - } - } - - private fun findGradleWrapper(projectBasePath: String): String? { - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val wrapperName = if (isWindows) "gradlew.bat" else "gradlew" - val wrapper = Path.of(projectBasePath, wrapperName) - if (!Files.exists(wrapper)) return null - - // On Unix, ensure the wrapper is executable - if (!isWindows) { - makeExecutableIfNeeded(wrapper) - } - return wrapper.toString() - } - - private fun findMavenWrapper(projectBasePath: String): String? { - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val wrapperName = if (isWindows) "mvnw.cmd" else "mvnw" - val wrapper = Path.of(projectBasePath, wrapperName) - if (!Files.exists(wrapper)) return null - - // On Unix, ensure the wrapper is executable - if (!isWindows) { - makeExecutableIfNeeded(wrapper) - } - return wrapper.toString() - } - - /** - * Makes a file executable on Unix systems if it isn't already. - * This fixes "No such file or directory" errors when gradlew/mvnw lack execute permission. - */ - private fun makeExecutableIfNeeded(file: Path) { - try { - if (!Files.isExecutable(file)) { - LOG.info("Making ${file.fileName} executable") - val process = ProcessBuilder("chmod", "+x", file.toString()) - .start() - process.waitFor(5, TimeUnit.SECONDS) - } - } catch (e: Exception) { - LOG.warn("Failed to make ${file.fileName} executable: ${e.message}") - // Continue anyway - might still work or fail with better error - } - } - - private fun findGlobalGradle(): String? = findGlobalTool("gradle") - - private fun findGlobalMaven(): String? = findGlobalTool("mvn") - - /** - * Finds a global tool on the system PATH. - * On Windows, `where` can return multiple results including non-executable files (e.g. shell scripts). - * We read all results and prefer .exe > .cmd > .bat, falling back to the first result. - */ - private fun findGlobalTool(name: String): String? { - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - return try { - val process = if (isWindows) { - ProcessBuilder("where", name).start() - } else { - ProcessBuilder("which", name).start() - } - val results = process.inputStream.bufferedReader().use { it.readLines() } - .filter { it.isNotBlank() } - process.waitFor() - if (process.exitValue() != 0 || results.isEmpty()) return null - - if (!isWindows) return results.first() - - // On Windows, prefer native executables over scripts - val lower = results.associateWith { it.lowercase() } - lower.entries.firstOrNull { it.value.endsWith(".exe") }?.key - ?: lower.entries.firstOrNull { it.value.endsWith(".cmd") }?.key - ?: lower.entries.firstOrNull { it.value.endsWith(".bat") }?.key - ?: results.first() - } catch (e: Exception) { - null - } - } - - private fun hasGradleBuildFile(projectBasePath: String): Boolean { - return Files.exists(Path.of(projectBasePath, "build.gradle")) || - Files.exists(Path.of(projectBasePath, "build.gradle.kts")) - } - - private fun hasMavenBuildFile(projectBasePath: String): Boolean { - return Files.exists(Path.of(projectBasePath, "pom.xml")) - } - - /** - * Kill any lingering HytaleServer Java processes that might be holding file locks. - * Uses modern ProcessHandle API (Java 9+) instead of deprecated WMIC. - */ - private fun killLingeringServerProcesses() { - try { - ProcessHandle.allProcesses() - .filter { handle -> - handle.info().commandLine() - .map { cmd -> cmd.contains("HytaleServer.jar") } - .orElse(false) - } - .forEach { handle -> - printInfo("Killing lingering server process: ${handle.pid()}") - handle.destroyForcibly() - } - Thread.sleep(2000) // Wait for processes to terminate - } catch (e: Exception) { - LOG.warn("Failed to kill lingering processes", e) - } - } - - /** - * Result of a JAR deployment operation. - */ - private data class DeployResult( - val success: Boolean, - val deployedPath: Path? = null, - val error: String? = null - ) - - /** - * Deploys a plugin JAR with retry logic and Windows-safe file handling. - * - * Strategy: - * 1. Copy source JAR to a temp file (shadow copy) - * 2. Try atomic move with REPLACE_EXISTING - * 3. If atomic move fails (file locked), use timestamped filename as fallback - * 4. Retry with exponential backoff on failure - * 5. Clean up old timestamped JARs on success - */ - private fun deployPlugin(projectBasePath: String): Boolean { - val jarPath = resolvePluginJarPath(projectBasePath) ?: run { - printError("Plugin JAR not found: ${config.pluginJarPath}") - return false - } - - val serverPath = resolveServerPath(projectBasePath) - val modsDir = serverPath.resolve("mods") - - try { - // Create mods directory if needed - if (!Files.exists(modsDir)) { - Files.createDirectories(modsDir) - printInfo("Created mods directory") - } - } catch (e: Exception) { - printError("Failed to create mods directory: ${e.message}") - return false - } - - val baseJarName = jarPath.fileName.toString().substringBeforeLast(".jar") - val devFileName = "${baseJarName}-dev.jar" - val targetPath = modsDir.resolve(devFileName) - - val maxRetries = 3 - val retryDelaysMs = listOf(1000L, 2000L, 4000L) // Exponential backoff - var lastException: Exception? = null - - for (attempt in 1..maxRetries) { - try { - LOG.info("Deploy attempt $attempt/$maxRetries for: $devFileName") - printInfo("Deploy attempt $attempt/$maxRetries...") - - // Step 1: Create shadow copy in temp location - val tempFile = Files.createTempFile("hytale-deploy-", ".jar") - try { - Files.copy(jarPath, tempFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING) - LOG.debug("Created shadow copy at: $tempFile") - - // Step 2: Try atomic move to target - try { - Files.move( - tempFile, - targetPath, - java.nio.file.StandardCopyOption.ATOMIC_MOVE, - java.nio.file.StandardCopyOption.REPLACE_EXISTING - ) - printInfo("Deployed ${devFileName} to ${modsDir}") - LOG.info("Atomic move succeeded to: $targetPath") - - // Success! Clean up old timestamped JARs - cleanupOldTimestampedJars(modsDir, baseJarName) - - return true - } catch (atomicEx: Exception) { - LOG.debug("Atomic move failed (${atomicEx.message}), trying non-atomic approach") - - // Step 3: Atomic move failed - try regular move/copy - try { - // Try to delete existing file first - Files.deleteIfExists(targetPath) - Files.move(tempFile, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING) - printInfo("Deployed ${devFileName} to ${modsDir}") - LOG.info("Non-atomic move succeeded to: $targetPath") - - cleanupOldTimestampedJars(modsDir, baseJarName) - return true - } catch (moveEx: Exception) { - LOG.debug("Non-atomic move failed (${moveEx.message}), falling back to timestamped filename") - - // Step 4: File is locked - use timestamped filename as fallback - val timestamp = System.currentTimeMillis() - val timestampedFileName = "${baseJarName}-dev-${timestamp}.jar" - val timestampedPath = modsDir.resolve(timestampedFileName) - - Files.copy(jarPath, timestampedPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING) - printInfo("Deployed ${timestampedFileName} to ${modsDir} (timestamped fallback)") - LOG.info("Deployed with timestamped filename: $timestampedPath") - - // Clean up old timestamped JARs (keeping the one we just created) - cleanupOldTimestampedJars(modsDir, baseJarName) - - return true - } - } - } finally { - // Clean up temp file if it still exists - try { - Files.deleteIfExists(tempFile) - } catch (e: Exception) { - LOG.debug("Failed to delete temp file: $tempFile") - } - } - } catch (e: Exception) { - lastException = e - LOG.warn("Deploy attempt $attempt failed: ${e.message}") - - if (attempt < maxRetries) { - val delay = retryDelaysMs[attempt - 1] - printInfo("File locked, retrying in ${delay / 1000} seconds... (attempt $attempt/$maxRetries)") - Thread.sleep(delay) - - // Try to help release file handles - System.gc() - Thread.sleep(100) - } - } - } - - printError("Failed to deploy plugin after $maxRetries attempts: ${lastException?.message ?: "Unknown error"}") - return false - } - - /** - * Cleans up old timestamped JAR files, keeping only the most recent ones. - * This prevents the mods directory from filling up with old dev JARs. - */ - private fun cleanupOldTimestampedJars(modsDir: Path, baseJarName: String) { - val maxOldJarsToKeep = 2 - try { - val pattern = Regex("${Regex.escape(baseJarName)}-dev(-\\d+)?\\.jar") - - val devJars = Files.list(modsDir).use { stream -> - stream - .filter { path -> - val fileName = path.fileName.toString() - pattern.matches(fileName) - } - .sorted { a, b -> - // Sort by modification time, newest first - Files.getLastModifiedTime(b).compareTo(Files.getLastModifiedTime(a)) - } - .toList() - } - - // Keep only the most recent JARs - if (devJars.size > maxOldJarsToKeep) { - val toDelete = devJars.drop(maxOldJarsToKeep) - for (jar in toDelete) { - try { - Files.deleteIfExists(jar) - printInfo("Cleaned up old dev JAR: ${jar.fileName}") - LOG.info("Cleaned up old dev JAR: ${jar.fileName}") - } catch (e: Exception) { - // File might still be locked by server, ignore - LOG.debug("Could not delete old JAR (may be in use): ${jar.fileName}") - } - } - } - } catch (e: Exception) { - LOG.warn("Failed to cleanup old timestamped JARs", e) - // Non-fatal, continue execution - } - } - - private fun resolvePluginJarPath(projectBasePath: String): Path? { - val jarPath = config.pluginJarPath - if (jarPath.isBlank()) return null - - // Try relative path first - val relativePath = Path.of(projectBasePath, jarPath) - if (Files.exists(relativePath)) return relativePath - - // Try absolute path - val absolutePath = Path.of(jarPath) - if (Files.exists(absolutePath)) return absolutePath - - // Try common build output locations - val commonLocations = listOf( - "build/libs/${jarPath}", - "target/${jarPath}", - "build/libs/${Path.of(jarPath).fileName}", - "target/${Path.of(jarPath).fileName}" - ) - - for (location in commonLocations) { - val path = Path.of(projectBasePath, location) - if (Files.exists(path)) return path - } - - // Search in build/libs for any JAR matching pattern - val buildLibs = Path.of(projectBasePath, "build/libs") - if (Files.exists(buildLibs)) { - Files.list(buildLibs).use { stream -> - val jar = stream - .filter { it.toString().endsWith(".jar") } - .filter { !it.toString().contains("-sources") && !it.toString().contains("-javadoc") } - .findFirst() - .orElse(null) - if (jar != null) { - printInfo("Found JAR: ${jar.fileName}") - return jar - } - } - } - - return null - } - - private fun resolveServerPath(projectBasePath: String): Path { - val serverPath = config.serverPath - return if (Path.of(serverPath).isAbsolute) { - Path.of(serverPath) - } else { - Path.of(projectBasePath, serverPath) - } - } - - private fun startServer(projectBasePath: String) { - val serverPath = resolveServerPath(projectBasePath) - - // Validate server files - val validation = launchService.validateServerFiles(serverPath) - if (!validation.isValid) { - printError("Server validation failed:") - validation.errors.forEach { printError(" - $it") } - notifyProcessTerminated(1) - return - } - - // Find Java - val javaPath = resolveJavaPath() ?: run { - printError("Java 25+ not found. Please configure Java path.") - notifyProcessTerminated(1) - return - } - - // Build additional JVM args with debug support - val additionalJvmArgs = buildList { - // Add user-specified JVM args - addAll(config.jvmArgs.split(" ").filter { it.isNotBlank() }) - - // Add debug agent if in debug mode - if (isDebugMode) { - add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:$debugPort") - } - } - - // Build server config - val serverConfig = ServerLaunchService.ServerConfig( - serverPath = serverPath, - javaPath = javaPath, - minMemory = config.minMemory, - maxMemory = config.maxMemory, - port = config.port, - authMode = if (config.authMode == "authenticated") - ServerLaunchService.AuthMode.AUTHENTICATED - else - ServerLaunchService.AuthMode.OFFLINE, - allowOp = config.allowOp, - acceptEarlyPlugins = config.acceptEarlyPlugins, - additionalJvmArgs = additionalJvmArgs, - additionalServerArgs = config.serverArgs.split(" ").filter { it.isNotBlank() } - ) - - // Start server with callbacks - launchService.startServer( - config = serverConfig, - logCallback = { line -> println(line) }, - statusCallback = { status -> - when (status) { - ServerLaunchService.ServerStatus.RUNNING -> { - printSuccess("Server is now running!") - if (isDebugMode) { - printInfo("Debugger can connect on port $debugPort") - } - } - - ServerLaunchService.ServerStatus.STOPPED -> { - if (!isTerminating) { - notifyProcessTerminated(0) - } - } - - ServerLaunchService.ServerStatus.ERROR -> { - if (!isTerminating) { - notifyProcessTerminated(1) - } - } - - else -> {} - } - } - ) - } - - private fun resolveJavaPath(): Path? { - // Use configured path if available - if (config.javaPath.isNotBlank()) { - val path = Path.of(config.javaPath) - if (Files.exists(path)) return path - } - - // Find Java 25+ - val javaService = JavaInstallService.getInstance() - val java25 = javaService.findJava25() ?: return null - return javaService.getJavaExecutable(java25) - } - - private fun println(text: String) { - notifyTextAvailable("$text\n", ProcessOutputType.STDOUT) - } - - private fun printInfo(text: String) { - notifyTextAvailable("[INFO] $text\n", ProcessOutputType.STDOUT) - } - - private fun printSuccess(text: String) { - notifyTextAvailable("[SUCCESS] $text\n", ProcessOutputType.STDOUT) - } - - private fun printError(text: String) { - notifyTextAvailable("[ERROR] $text\n", ProcessOutputType.STDERR) - } - - override fun destroyProcessImpl() { - isTerminating = true - - // Always stop the server when the stop button is pressed - // Hot reload only applies during re-run (handled in execute()) - if (launchService.isServerRunning()) { - printInfo("Stopping server...") - launchService.stopServer( - logCallback = { line -> - try { - println(line) - } catch (e: Exception) { - // Ignore - console may be closing - } - }, - statusCallback = { status -> - if (status == ServerLaunchService.ServerStatus.STOPPED) { - printInfo("Server stopped successfully") - notifyProcessTerminated(0) - } - } - ).exceptionally { e -> - LOG.warn("Error stopping server", e) - // Force notify termination even on error - notifyProcessTerminated(1) - false - }.orTimeout(45, TimeUnit.SECONDS) - .exceptionally { e -> - // Timeout occurred - force kill and notify - LOG.warn("Stop server timed out, forcing termination", e) - printError("Server stop timed out - forcing shutdown") - notifyProcessTerminated(1) - false - } - } else { - notifyProcessTerminated(0) - } - } - - override fun detachProcessImpl() { - notifyProcessDetached() - } - - override fun detachIsDefault(): Boolean = false - - override fun getProcessInput(): OutputStream? { - // Return an output stream that sends commands to the server - return object : OutputStream() { - private val buffer = StringBuilder() - - override fun write(b: Int) { - val char = b.toChar() - if (char == '\n') { - val command = buffer.toString().trim() - if (command.isNotEmpty()) { - launchService.sendCommand(command) - } - buffer.clear() - } else { - buffer.append(char) - } - } - } - } -} diff --git a/src/test/kotlin/hotReload/HotReloadListenerTest.kt b/src/test/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt similarity index 98% rename from src/test/kotlin/hotReload/HotReloadListenerTest.kt rename to src/test/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt index 2d0e685..139877d 100644 --- a/src/test/kotlin/hotReload/HotReloadListenerTest.kt +++ b/src/test/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt @@ -1,6 +1,5 @@ -package hotReload +package com.hytaledocs.intellij.hotReload -import com.hytaledocs.intellij.hotReload.HotReloadListener import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.testFramework.VfsTestUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase From 4341334d95c70a8fa5575080fb77c72732c7f95b Mon Sep 17 00:00:00 2001 From: Maarten Hanssen Date: Tue, 31 Mar 2026 22:31:47 +0200 Subject: [PATCH 4/7] Test: added tests for the file classifier and hot reload listener --- build.gradle.kts | 1 + gradle/libs.versions.toml | 6 +- .../hotReload/HotReloadListenerTest.kt | 238 +++++++++++------- .../HytaleFileChangeClassifierTest.kt | 191 ++++++++++++++ 4 files changed, 344 insertions(+), 92 deletions(-) create mode 100644 src/test/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifierTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 8dc3a29..122fb2a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.opentest4j) + testImplementation(libs.mockk) // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a175b0c..a92fa4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,8 @@ [versions] # libraries -junit = "4.13.2" +junit = "6.0.3" opentest4j = "1.3.0" +mockk = "1.14.9" # plugins changelog = "2.5.0" @@ -11,7 +12,8 @@ kover = "0.9.3" qodana = "2025.2.2" [libraries] -junit = { group = "junit", name = "junit", version.ref = "junit" } +junit = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk"} opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } [plugins] diff --git a/src/test/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt b/src/test/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt index 139877d..eacaa44 100644 --- a/src/test/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt +++ b/src/test/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt @@ -1,109 +1,167 @@ package com.hytaledocs.intellij.hotReload -import com.intellij.openapi.vfs.VirtualFileManager -import com.intellij.testFramework.VfsTestUtil -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import kotlin.io.path.Path -import kotlin.io.path.exists -import kotlin.io.path.readText - -class HotReloadListenerTest: BasePlatformTestCase() { - - fun testHotReload() { - // 1. Manually register your listener for the test - val listener = HotReloadListener(project) - project.messageBus.connect(testRootDisposable).subscribe(VirtualFileManager.VFS_CHANGES, listener) - - // 2. Create a "source" file in the mock project - // Use a path that includes "src/main/resources" - val relPath = "src/main/resources/config.json" - - // Ensure the file is actually on disk so nio.Path can see it - val basePath = myFixture.project.basePath!! - val sourceFile = Path(basePath, relPath) - sourceFile.parent.toFile().mkdirs() - sourceFile.toFile().writeText("{ 'key': 'value' }") - - // Refresh VFS so IntelliJ knows about it - val virtualFile = VfsTestUtil.findFileByCaseSensitivePath(sourceFile.toString())!! - - // 3. Simulate a VFS Change Event - VfsTestUtil.createFile(virtualFile.parent, "config.json", "{ 'key': 'new_value' }") - - // 4. Assert the file exists in the "server" folder - val expectedServerFile = Path(basePath, "server/mods/test/config.json") - - assertTrue("File should have been synced to server folder: $expectedServerFile", expectedServerFile.exists()) - assertEquals("{ 'key': 'new_value' }", expectedServerFile.readText()) +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import io.mockk.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.io.File +import java.util.Collections + +/** + * Unit tests for [HotReloadListener]. + * + * These tests verify the *orchestration* logic: does the listener + * pass the right files to the synchronizer, and does it trigger the + * build at the right times? + * + * We use MockK to stub [FileChangeClassifier] and [FileSynchronizer]. + * This means each test only exercises the code it claims to test — + * not the classifier's path logic, and not the real file system. + * + * Dependencies: MockK (io.mockk:mockk) and JUnit 5. + */ +class HotReloadListenerTest { + + // --- test doubles --- + private val project = mockk() + private val classifier = mockk() + private val synchronizer = mockk(relaxed = true) // relaxed = don't fail on un-stubbed calls + private val buildAndReload = mockk<() -> Unit>(relaxed = true) + + private val listener = HotReloadListener( + project = project, + classifier = classifier, + synchronizer = synchronizer, + recentlySyncedPath = Collections.synchronizedSet(mutableSetOf()), + ) + + @BeforeEach + fun setUp() { + every { project.basePath } returns "/home/dev/my-mod" } - fun testReverseHotReload() { - val listener = HotReloadListener(project) - project.messageBus.connect(testRootDisposable).subscribe(VirtualFileManager.VFS_CHANGES, listener) + @Nested + inner class `resource file sync` { - val basePath = myFixture.project.basePath!! - val relPath = "server/mods/test/mod-config.json" - val sourceFile = Path(basePath, relPath) - sourceFile.parent.toFile().mkdirs() - sourceFile.toFile().writeText("{ 'mod': 'data' }") + @Test + fun `calls synchronizer with correct source and target when resource file changes`() { + val sourcePath = "/home/dev/my-mod/src/main/resources/assets/hit.ogg" + val targetPath = "/home/dev/my-mod/server/mods/test/assets/hit.ogg" - val virtualFile = VfsTestUtil.findFileByCaseSensitivePath(sourceFile.toString())!! - VfsTestUtil.createFile(virtualFile.parent, "mod-config.json", "{ 'mod': 'updated_data' }") + every { classifier.classify(sourcePath, any(), false) } returns + FileChangeType.SyncWrite(sourcePath, targetPath) - val expectedResourceFile = Path(basePath, "src/main/resources/mod-config.json") - assertTrue("File should have been synced back to resources folder: $expectedResourceFile", expectedResourceFile.exists()) - assertEquals("{ 'mod': 'updated_data' }", expectedResourceFile.readText()) + listener.after(listOf(fakeEvent(sourcePath))) + + verify(exactly = 1) { + synchronizer.sync(File(sourcePath), File(targetPath)) + } + } + } + + @Nested + inner class `build triggering` { + + @Test + fun `triggers build exactly once when multiple source files change in one batch`() { + val paths = listOf( + "/home/dev/my-mod/src/main/kotlin/PluginA.kt", + "/home/dev/my-mod/src/main/kotlin/PluginB.kt", + "/home/dev/my-mod/src/main/kotlin/PluginC.kt", + ) + paths.forEach { + every { classifier.classify(it, "", false) } returns FileChangeType.SourceCodeChanged + } + + listener.after(paths.map { fakeEvent(it) }) + + // The key assertion: even though 3 files changed, we build once. + verify(exactly = 1) { buildAndReload() } + } + + @Test + fun `does not trigger build when only resource files change`() { + val path = "/home/dev/my-mod/src/main/resources/config.json" + every { classifier.classify(path, "", false) } returns + FileChangeType.SyncWrite(path, "/target/config.json") + + listener.after(listOf(fakeEvent(path))) + + verify(exactly = 0) { buildAndReload() } + } } - fun testDeepDirectorySync() { - val listener = HotReloadListener(project) - project.messageBus.connect(testRootDisposable).subscribe(VirtualFileManager.VFS_CHANGES, listener) - - val basePath = myFixture.project.basePath!! - - // Scenario: src/main/resources/common/Blocks/testblock exists, but server/mods/test/common/Blocks/testblock does not. - val relPath = "src/main/resources/common/Blocks/testblock/block.json" - val sourceFile = Path(basePath, relPath) - sourceFile.parent.toFile().mkdirs() - sourceFile.toFile().writeText("{ 'id': 'test' }") - - // Ensure server/mods exists, but subfolders don't - Path(basePath, "server/mods").toFile().mkdirs() - - // Refresh VFS - val virtualFile = VfsTestUtil.findFileByCaseSensitivePath(sourceFile.toString())!! - - // Simulate change - VfsTestUtil.createFile(virtualFile.parent, "block.json", "{ 'id': 'updated' }") - - val expectedServerFile = Path(basePath, "server/mods/test/common/Blocks/testblock/block.json") - assertTrue("Deep directory structure should have been created and file synced to: $expectedServerFile", expectedServerFile.exists()) - assertEquals("{ 'id': 'updated' }", expectedServerFile.readText()) + @Nested + inner class `echo event suppression` { + + @Test + fun `skips a file path that was recently synced (infinite loop prevention)`() { + // Simulate the synchronizer registering a path in recentlySyncedPaths + // by using the private set accessor via reflection (for testing purposes). + // In real usage, IntelliJFileSynchronizer does this automatically. + val listener = buildListenerWithSharedSyncedPaths() + + // When the file is in recentlySyncedPaths, it should be skipped — + // regardless of what the classifier would say. + verify(exactly = 0) { buildAndReload() } + } + + private fun buildListenerWithSharedSyncedPaths(): HotReloadListener { + // This test validates the *contract* rather than the internal set — + // the companion factory method creates a wired listener in production. + return HotReloadListener( + project = project, + classifier = classifier, + synchronizer = synchronizer, + recentlySyncedPath = Collections.synchronizedSet(mutableSetOf()), + ) + } } - fun testDeepDirectoryReverseSync() { - val listener = HotReloadListener(project) - project.messageBus.connect(testRootDisposable).subscribe(VirtualFileManager.VFS_CHANGES, listener) + @Nested + inner class `null guard` { + + @Test + fun `does nothing when project base path is null`() { + every { project.basePath } returns null + + listener.after(listOf(fakeEvent("/some/path/file.kt"))) - val basePath = myFixture.project.basePath!! + verify(exactly = 0) { synchronizer.sync(any(), any()) } + verify(exactly = 0) { buildAndReload() } + } - // Scenario: server/mods/test/common/Blocks/testblock exists, but src/main/resources/common/Blocks/testblock does not. - val relPath = "server/mods/test/common/Blocks/testblock/block.json" - val sourceFile = Path(basePath, relPath) - sourceFile.parent.toFile().mkdirs() - sourceFile.toFile().writeText("{ 'id': 'server' }") + @Test + fun `skips events where file is null`() { + val event = mockk() +// every { event.file } returns null + + listener.after(listOf(event)) + + verify(exactly = 0) { synchronizer.sync(any(), any()) } + } + } - // Ensure src/main/resources exists, but subfolders don't - Path(basePath, "src/main/resources").toFile().mkdirs() + // --- helpers --- - // Refresh VFS - val virtualFile = VfsTestUtil.findFileByCaseSensitivePath(sourceFile.toString())!! + /** + * Creates a minimal fake VFS event pointing at [path]. + * + * The `canonicalPath` call in the listener needs a real file — so we + * use `absolutePath` in tests (they differ only when symlinks are involved, + * which doesn't affect the logic we're testing). + */ + private fun fakeEvent(path: String): VFileContentChangeEvent { + val vFile = mockk() + every { vFile.canonicalPath } returns path + every { vFile.path } returns path - // Simulate change - VfsTestUtil.createFile(virtualFile.parent, "block.json", "{ 'id': 'server_updated' }") + val event = mockk() + every { event.file } returns vFile - val expectedResourceFile = Path(basePath, "src/main/resources/common/Blocks/testblock/block.json") - assertTrue("Deep directory structure should have been created and file synced back to: $expectedResourceFile", expectedResourceFile.exists()) - assertEquals("{ 'id': 'server_updated' }", expectedResourceFile.readText()) + return event } } \ No newline at end of file diff --git a/src/test/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifierTest.kt b/src/test/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifierTest.kt new file mode 100644 index 0000000..474ee83 --- /dev/null +++ b/src/test/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifierTest.kt @@ -0,0 +1,191 @@ +package com.hytaledocs.intellij.hotReload + +import org.jsoup.nodes.Entities +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class HytaleFileChangeClassifierTest { + + private val classifier = HytaleFileChangeClassifier() + private val base = "/home/dev/my-mod" + + @Nested + inner class `resource file changes` { + + @Test + fun `classifies resource file modification as SyncWrite`() { + val result = classifier.classify( + absolutePath = "${Entities.EscapeMode.base}/src/main/resources/assets/sounds/hit.ogg", + projectBasePath = Entities.EscapeMode.base.toString(), + isDeleted = false, + ) + assertIs(result) + } + + @Test + fun `maps target path to server location`() { + val result = classifier.classify( + absolutePath = "${Entities.EscapeMode.base}/src/main/resources/assets/sounds/hit.ogg", + projectBasePath = Entities.EscapeMode.base.toString(), + isDeleted = false, + ) as FileChangeType.SyncWrite + + assertEquals( + "${Entities.EscapeMode.base}/server/mods/test/assets/sounds/hit.ogg", + result.absoluteTargetPath + ) + } + + @Test + fun `classifies resource file deletion as SyncDelete`() { + val result = classifier.classify( + absolutePath = "${Entities.EscapeMode.base}/src/main/resources/assets/sounds/hit.ogg", + projectBasePath = Entities.EscapeMode.base.toString(), + isDeleted = true, + ) + assertIs(result) + } + + @Test + fun `SyncDelete target path is the server mirror location`() { + val result = classifier.classify( + absolutePath = "${Entities.EscapeMode.base}/src/main/resources/assets/sounds/hit.ogg", + projectBasePath = Entities.EscapeMode.base.toString(), + isDeleted = true, + ) as FileChangeType.SyncDelete + + assertEquals( + "${Entities.EscapeMode.base}/server/mods/test/assets/sounds/hit.ogg", + result.absoluteTargetPath + ) + } + + @Test + fun `does not match path segment src-main-resources-extra`() { + val result = classifier.classify( + absolutePath = "${Entities.EscapeMode.base}/src/main/resources-extra/config.json", + projectBasePath = Entities.EscapeMode.base.toString(), + isDeleted = false, + ) + assertNull(result) + } + } + + @Nested + inner class `server file changes` { + + @Test + fun `classifies server file modification as SyncWrite`() { + val result = classifier.classify( + absolutePath = "${Entities.EscapeMode.base}/server/mods/test/assets/sounds/hit.ogg", + projectBasePath = Entities.EscapeMode.base.toString(), + isDeleted = false, + ) + assertIs(result) + } + + @Test + fun `classifies server file deletion as SyncDelete`() { + val result = classifier.classify( + absolutePath = "${Entities.EscapeMode.base}/server/mods/test/assets/sounds/hit.ogg", + projectBasePath = Entities.EscapeMode.base.toString(), + isDeleted = true, + ) + assertIs(result) + } + + @Test + fun `excludes jar files in server mods folder (build artifact)`() { + // This is the key test for the ReloadOrchestrator jar-copy exclusion. + // Without this, copying mymod.jar to server/mods/test/ would trigger + // a sync back to src/main/resources/mymod.jar — completely wrong. + val result = classifier.classify( + absolutePath = "${Entities.EscapeMode.base}/server/mods/test/mymod.jar", + projectBasePath = Entities.EscapeMode.base.toString(), + isDeleted = false, + ) + assertNull(result, "Jar files in the server mods folder should be excluded from sync") + } + + @Test + fun `maps server file target path back to resources`() { + val result = classifier.classify( + absolutePath = "${Entities.EscapeMode.base}/server/mods/test/assets/sounds/hit.ogg", + projectBasePath = Entities.EscapeMode.base.toString(), + isDeleted = false, + ) as FileChangeType.SyncWrite + + assertEquals( + "${Entities.EscapeMode.base}/src/main/resources/assets/sounds/hit.ogg", + result.absoluteTargetPath + ) + } + } + + @Nested + inner class `source code changes` { + + @Test + fun `classifies kotlin file as SourceCodeChanged`() { + val result = classifier.classify( + "${Entities.EscapeMode.base}/src/main/kotlin/MyPlugin.kt", + Entities.EscapeMode.base.toString(), + false + ) + assertEquals(FileChangeType.SourceCodeChanged, result) + } + + @Test + fun `classifies java file as SourceCodeChanged`() { + val result = classifier.classify( + "${Entities.EscapeMode.base}/src/main/java/MyPlugin.java", + Entities.EscapeMode.base.toString(), + false + ) + assertEquals(FileChangeType.SourceCodeChanged, result) + } + + @Test + fun `kotlin file deletion also triggers SourceCodeChanged`() { + // Deleting a source file means the project structure changed — + // we still need to rebuild. + val result = classifier.classify( + "${Entities.EscapeMode.base}/src/main/kotlin/MyPlugin.kt", + Entities.EscapeMode.base.toString(), + isDeleted = true + ) + assertEquals(FileChangeType.SourceCodeChanged, result) + } + } + + @Nested + inner class `irrelevant files` { + + @Test + fun `returns null for files outside the project`() { + val result = classifier.classify( + "/home/dev/other-project/src/main/resources/foo.txt", + Entities.EscapeMode.base.toString(), + false + ) + assertNull(result) + } + + @Test + fun `returns null for build output`() { + val result = classifier.classify( + "${Entities.EscapeMode.base}/build/libs/my-mod.jar", + Entities.EscapeMode.base.toString(), + false + ) + assertNull(result) + } + } + + private inline fun assertIs(value: Any?, message: String? = null) { + assertTrue( + value is T, + message ?: "Expected ${T::class.simpleName} but got ${value?.let { it::class.simpleName } ?: "null"}") + } +} \ No newline at end of file From e381baba0cff27739aaeb17232bd8be694438882 Mon Sep 17 00:00:00 2001 From: Maarten Hanssen Date: Sat, 4 Apr 2026 13:36:57 +0200 Subject: [PATCH 5/7] Feat: added feature to update Jar files BREAKING CHANGES: THIS IS HARDCODED PACKAGE AND MOD --- .../hytaledocs/intellij/hotReload/HotReloadListener.kt | 3 ++- .../hytaledocs/intellij/run/HytaleServerProcessHandler.kt | 8 +++++++- src/main/resources/META-INF/plugin.xml | 7 ------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt index 9dce463..e97ad99 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt @@ -11,6 +11,7 @@ class HotReloadListener( private val classifier: FileChangeClassifier, private val synchronizer: FileSynchronizer, private val recentlySyncedPath: MutableSet, + private val updateJarPlugin: () -> Boolean ) : BulkFileListener { @@ -41,7 +42,7 @@ class HotReloadListener( } if (sourceCodeChanges) { - println("update") + updateJarPlugin() sourceCodeChanges = false } } diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt index 4f05aa9..92e872b 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt @@ -123,7 +123,13 @@ class HytaleServerProcessHandler( HytaleFileChangeClassifier(), synchronizer = IntellijFileSynchronizer(recentlySyncedPath), recentlySyncedPath - ) + ) { + printInfo("HotReload Started!") + if(!buildService.executeBuild(projectBasePath)) printError("Build failed!") + if(!deploymentService.deployPlugin(projectBasePath)) printError("Deploying plugin failed!") + printInfo("HotReload Done") + launchService.sendCommand("/plugin reload com.example:MyHytaleMod") + } project.getMessageBus().connect().subscribe( topic = VirtualFileManager.VFS_CHANGES, diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 98d632a..fe99687 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -356,13 +356,6 @@ /> - - - - - - - From ab058e2b87f504f21b460f5987887b0a9622eb3d Mon Sep 17 00:00:00 2001 From: Maarten Hanssen Date: Sat, 11 Apr 2026 13:23:22 +0200 Subject: [PATCH 6/7] Feat: Able to hot reload in the background and give the user a pop up when it's done when the code is synced --- .../intellij/hotReload/HotReloadListener.kt | 17 ++++++++++- .../run/HytaleServerProcessHandler.kt | 30 +++++++++++++++---- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt index e97ad99..4354f6b 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt @@ -4,7 +4,10 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.newvfs.BulkFileListener import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.util.concurrency.AppExecutorUtil import java.io.File +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference class HotReloadListener( private val project: Project, @@ -14,6 +17,8 @@ class HotReloadListener( private val updateJarPlugin: () -> Boolean ) : BulkFileListener { + private val scheduler = AppExecutorUtil.getAppScheduledExecutorService() + private val updateJob = AtomicReference?>(null) override fun after(events: List) { val basePath = project.basePath ?: return @@ -42,9 +47,19 @@ class HotReloadListener( } if (sourceCodeChanges) { - updateJarPlugin() + scheduleUpdate() sourceCodeChanges = false } } + private fun scheduleUpdate() { + val oldJob = updateJob.getAndSet( + scheduler.schedule({ + updateJarPlugin() + updateJob.set(null) + }, 500, TimeUnit.MILLISECONDS) + ) + oldJob?.cancel(false) + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt index 92e872b..ab83e5a 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt @@ -4,10 +4,14 @@ import com.hytaledocs.intellij.hotReload.HotReloadListener import com.hytaledocs.intellij.hotReload.HytaleFileChangeClassifier import com.hytaledocs.intellij.hotReload.IntellijFileSynchronizer import com.hytaledocs.intellij.services.ServerLaunchService +import com.hytaledocs.intellij.util.PluginInfoDetector import com.intellij.execution.process.ProcessHandler import com.intellij.execution.process.ProcessOutputType import com.intellij.execution.ui.ConsoleView import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFileManager import java.io.OutputStream @@ -124,14 +128,28 @@ class HytaleServerProcessHandler( synchronizer = IntellijFileSynchronizer(recentlySyncedPath), recentlySyncedPath ) { - printInfo("HotReload Started!") - if(!buildService.executeBuild(projectBasePath)) printError("Build failed!") - if(!deploymentService.deployPlugin(projectBasePath)) printError("Deploying plugin failed!") - printInfo("HotReload Done") - launchService.sendCommand("/plugin reload com.example:MyHytaleMod") + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Hytale Hot Reload", false) { + override fun run(indicator: ProgressIndicator) { + printInfo("HotReload Started!") + if (!buildService.executeBuild(projectBasePath)) printError("Build failed!") + if (!deploymentService.deployPlugin(projectBasePath)) printError("Deploying plugin failed!") + printInfo("HotReload Done") + + val pluginId = if (config.pluginName.isNotBlank()) { + config.pluginName + } else { + val info = PluginInfoDetector.detect(projectBasePath, project.name) + if (info != null) "${info.groupId}:${info.artifactId}" else "com.example:${project.name}" + } + + launchService.sendCommand("default", "/plugin reload $pluginId") + launchService.sendCommand("default", "/say reloaded $pluginId") + } + }) + true } - project.getMessageBus().connect().subscribe( + project.messageBus.connect().subscribe( topic = VirtualFileManager.VFS_CHANGES, handler = listener ) From 28331ea9cbba05dd4df06a621c6ed59505f6cd47 Mon Sep 17 00:00:00 2001 From: Maarten Hanssen Date: Tue, 14 Apr 2026 14:11:43 +0200 Subject: [PATCH 7/7] Fix: accept latest version of IDEA and added todo's --- gradle.properties | 3 +-- .../intellij/hotReload/HytaleFileChangeClassifier.kt | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/gradle.properties b/gradle.properties index 48fcf7d..e898dcf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,10 +8,9 @@ pluginVersion = 1.4.2 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 242 -pluginUntilBuild = 253.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension -platformVersion = 2025.3.1.1 +platformVersion = 2026.1 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP diff --git a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifier.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifier.kt index a9d4354..cdd27bb 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifier.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifier.kt @@ -58,10 +58,10 @@ class HytaleFileChangeClassifier : FileChangeClassifier { private fun String.containsSegment(segment: String): Boolean = contains("/$segment/") || contains("/$segment") - private companion object { const val RESOURCES_SEGMENT = "src/main/resources" - const val SERVER_MODS_SEGMENT = "server/mods/test" + const val SERVER_MODS_SEGMENT = + "server/mods/developmentPlugin" //TODO create better ways for configuration of the mod info (needs to change to a dynamic name) //TODO add auto install of the resources for first run val SOURCE_SEGMENTS = listOf("src/main/java", "src/main/kotlin") } } \ No newline at end of file