diff --git a/build.gradle.kts b/build.gradle.kts index 34e1736..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 { @@ -75,6 +76,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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3fd64a1..a92fa4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,19 @@ [versions] # libraries -junit = "4.13.2" +junit = "6.0.3" opentest4j = "1.3.0" +mockk = "1.14.9" # 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" [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/gradlew b/gradlew old mode 100644 new mode 100755 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 new file mode 100644 index 0000000..4354f6b --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListener.kt @@ -0,0 +1,65 @@ +package com.hytaledocs.intellij.hotReload + +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, + private val classifier: FileChangeClassifier, + private val synchronizer: FileSynchronizer, + private val recentlySyncedPath: MutableSet, + 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 + var sourceCodeChanges = false + + for (event in events) { + val path = event.path + val canonicalPath = File(path).canonicalPath + + if (recentlySyncedPath.remove(canonicalPath)) continue + + val isDelete = event is VFileDeleteEvent + + when (val change = classifier.classify(path, basePath, isDelete)) { + is FileChangeType.SyncWrite -> synchronizer.sync( + File(change.absoluteSourcePath), + File(change.absoluteTargetPath) + ) + + is FileChangeType.SyncDelete -> synchronizer.delete(File(change.absoluteTargetPath)) + + FileChangeType.SourceCodeChanged -> sourceCodeChanges = true + + null -> Unit + } + } + + if (sourceCodeChanges) { + 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/hotReload/HytaleFileChangeClassifier.kt b/src/main/kotlin/com/hytaledocs/intellij/hotReload/HytaleFileChangeClassifier.kt new file mode 100644 index 0000000..cdd27bb --- /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/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 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..ab83e5a --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerProcessHandler.kt @@ -0,0 +1,334 @@ +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.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 +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 + ) { + 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.messageBus.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 7a70b27..0116a70 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleServerRunState.kt @@ -1,7 +1,5 @@ package com.hytaledocs.intellij.run -import com.hytaledocs.intellij.services.JavaInstallService -import com.hytaledocs.intellij.services.ServerLaunchService import com.intellij.execution.DefaultExecutionResult import com.intellij.execution.ExecutionResult import com.intellij.execution.Executor @@ -9,19 +7,13 @@ 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 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 + /** * Execution state for Hytale Server run configuration. @@ -84,670 +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 (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/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) { diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 93000ef..fe99687 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/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt b/src/test/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt new file mode 100644 index 0000000..eacaa44 --- /dev/null +++ b/src/test/kotlin/com/hytaledocs/intellij/hotReload/HotReloadListenerTest.kt @@ -0,0 +1,167 @@ +package com.hytaledocs.intellij.hotReload + +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" + } + + @Nested + inner class `resource file sync` { + + @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" + + every { classifier.classify(sourcePath, any(), false) } returns + FileChangeType.SyncWrite(sourcePath, targetPath) + + 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() } + } + } + + @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()), + ) + } + } + + @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"))) + + verify(exactly = 0) { synchronizer.sync(any(), any()) } + verify(exactly = 0) { buildAndReload() } + } + + @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()) } + } + } + + // --- helpers --- + + /** + * 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 + + val event = mockk() + every { event.file } returns vFile + + 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