diff --git a/.gitignore b/.gitignore index 9af4432..a538258 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ .qodana build .claude/ + +local.properties +CLAUDE.local.md diff --git a/build.gradle.kts b/build.gradle.kts index 2d6f35a..e0705ba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ import org.jetbrains.changelog.Changelog import org.jetbrains.changelog.markdownToHTML import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import java.util.Properties plugins { id("java") // Java support @@ -9,6 +10,7 @@ plugins { alias(libs.plugins.changelog) // Gradle Changelog Plugin alias(libs.plugins.qodana) // Gradle Qodana Plugin alias(libs.plugins.kover) // Gradle Kover Plugin + alias(libs.plugins.protobuf) // Protocol Buffers } group = providers.gradleProperty("pluginGroup").get() @@ -39,6 +41,10 @@ dependencies { implementation("com.google.code.gson:gson:2.11.0") // OGG Vorbis audio support for asset preview implementation("com.github.trilarion:java-vorbis-support:1.2.1") + // WebSocket server for Dev Bridge + implementation(libs.javaWebsocket) + // Protocol Buffers runtime + implementation(libs.protobuf) testImplementation(libs.junit) testImplementation(libs.opentest4j) @@ -136,6 +142,49 @@ kover { } } +// Configure Protocol Buffers - read more: https://github.com/google/protobuf-gradle-plugin +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}" + } +} + +// Ensure generated protobuf sources are included in the source set +sourceSets { + main { + java { + srcDirs(layout.buildDirectory.dir("generated/source/proto/main/java")) + } + } +} + +// Gradle Tooling Extension configuration - following MinecraftDev pattern +// This extension runs in Gradle's process during IntelliJ sync to detect the hytale-dev plugin +val gradleToolingExtension: Configuration by configurations.creating + +val gradleToolingExtensionSourceSet: SourceSet = sourceSets.create("gradle-tooling-extension") { + configurations.named(compileOnlyConfigurationName) { + extendsFrom(gradleToolingExtension) + } +} + +// Create a separate JAR for the tooling extension +val gradleToolingExtensionJar = tasks.register(gradleToolingExtensionSourceSet.jarTaskName) { + from(gradleToolingExtensionSourceSet.output) + archiveClassifier.set("gradle-tooling-extension") +} + +dependencies { + // Embed the tooling extension JAR in the plugin + implementation(files(gradleToolingExtensionJar)) + + // Dependencies for the gradle-tooling-extension (runs in Gradle, not IntelliJ) + gradleToolingExtension(gradleApi()) + gradleToolingExtension(kotlin("stdlib")) + gradleToolingExtension(libs.gradleToolingExtension) + gradleToolingExtension(libs.annotations) +} + tasks { wrapper { gradleVersion = providers.gradleProperty("gradleVersion").get() @@ -154,6 +203,18 @@ tasks { "-Dide.locale=fr_FR" ) } + + // Allow overwriting the IDE runtime with local JBR for testing/compatibility + val localProperties = project.rootProject.file("local.properties") + if (localProperties.exists()) { + val p = Properties().apply { load(localProperties.inputStream()) } + val jbrPath = p.getProperty("idea.runtimeDir") + + if (jbrPath != null) { + logger.info("Using overridden JBR path: $jbrPath") + runtimeDirectory.set(file(jbrPath)) + } + } } } diff --git a/gradle.properties b/gradle.properties index a0cc2e1..9bfb4d7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,7 @@ platformVersion = 2025.3.1.1 # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP platformPlugins = # Example: platformBundledPlugins = com.intellij.java -platformBundledPlugins = com.intellij.java +platformBundledPlugins = com.intellij.java, org.jetbrains.plugins.gradle # Example: platformBundledModules = intellij.spellchecker platformBundledModules = diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3fd64a1..5b397d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,8 @@ # libraries junit = "4.13.2" opentest4j = "1.3.0" +protobuf = "3.25.1" +javaWebsocket = "1.5.7" # plugins changelog = "2.5.0" @@ -9,10 +11,18 @@ intelliJPlatform = "2.10.5" kotlin = "2.2.21" kover = "0.9.3" qodana = "2025.2.2" +protobufPlugin = "0.9.4" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } +protobuf = { group = "com.google.protobuf", name = "protobuf-java", version.ref = "protobuf" } +javaWebsocket = { group = "org.java-websocket", name = "Java-WebSocket", version.ref = "javaWebsocket" } + +# Gradle Tooling Extension - version must match IntelliJ build number +# Find version with: afterEvaluate { println(intellijPlatform.productInfo.buildNumber) } +gradleToolingExtension = { module = "com.jetbrains.intellij.gradle:gradle-tooling-extension", version = "253.29346.240" } +annotations = { group = "org.jetbrains", name = "annotations", version = "24.0.0" } [plugins] changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } @@ -20,3 +30,4 @@ intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "inte kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } +protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle.kts b/settings.gradle.kts index 1da2537..ac1d91c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,5 @@ rootProject.name = "hytale-intellij-plugin" plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } diff --git a/src/gradle-tooling-extension/java/com/hytaledocs/intellij/gradle/tooling/HytaleDevModel.java b/src/gradle-tooling-extension/java/com/hytaledocs/intellij/gradle/tooling/HytaleDevModel.java new file mode 100644 index 0000000..240a2dd --- /dev/null +++ b/src/gradle-tooling-extension/java/com/hytaledocs/intellij/gradle/tooling/HytaleDevModel.java @@ -0,0 +1,18 @@ +package com.hytaledocs.intellij.gradle.tooling; + +/** + * Model interface for querying Hytale Dev plugin information during Gradle sync. + * This interface is used by both the ModelBuilder (in Gradle process) and the + * ProjectResolverExtension (in IntelliJ process). + */ +public interface HytaleDevModel { + /** + * Returns true if the net.janrupf.hytale-dev Gradle plugin is applied to this project. + */ + boolean hasHytaleDevPlugin(); + + /** + * Returns the version of the Hytale Dev plugin, if available. + */ + String getPluginVersion(); +} diff --git a/src/gradle-tooling-extension/java/com/hytaledocs/intellij/gradle/tooling/HytaleDevModelImpl.java b/src/gradle-tooling-extension/java/com/hytaledocs/intellij/gradle/tooling/HytaleDevModelImpl.java new file mode 100644 index 0000000..3288d33 --- /dev/null +++ b/src/gradle-tooling-extension/java/com/hytaledocs/intellij/gradle/tooling/HytaleDevModelImpl.java @@ -0,0 +1,29 @@ +package com.hytaledocs.intellij.gradle.tooling; + +import java.io.Serializable; + +/** + * Serializable implementation of HytaleDevModel. + * This class is instantiated in the Gradle process and serialized to IntelliJ. + */ +public class HytaleDevModelImpl implements HytaleDevModel, Serializable { + private static final long serialVersionUID = 1L; + + private final boolean hasPlugin; + private final String version; + + public HytaleDevModelImpl(boolean hasPlugin, String version) { + this.hasPlugin = hasPlugin; + this.version = version; + } + + @Override + public boolean hasHytaleDevPlugin() { + return hasPlugin; + } + + @Override + public String getPluginVersion() { + return version; + } +} diff --git a/src/gradle-tooling-extension/kotlin/com/hytaledocs/intellij/gradle/tooling/HytaleDevModelBuilder.kt b/src/gradle-tooling-extension/kotlin/com/hytaledocs/intellij/gradle/tooling/HytaleDevModelBuilder.kt new file mode 100644 index 0000000..d777c36 --- /dev/null +++ b/src/gradle-tooling-extension/kotlin/com/hytaledocs/intellij/gradle/tooling/HytaleDevModelBuilder.kt @@ -0,0 +1,41 @@ +package com.hytaledocs.intellij.gradle.tooling + +import org.gradle.api.Project +import org.jetbrains.plugins.gradle.tooling.ErrorMessageBuilder +import org.jetbrains.plugins.gradle.tooling.ModelBuilderService + +/** + * ModelBuilder that runs in the Gradle process during IntelliJ Gradle sync. + * Detects whether the net.janrupf.hytale-dev plugin is applied to the project. + */ +class HytaleDevModelBuilder : ModelBuilderService { + + companion object { + private const val HYTALE_DEV_PLUGIN_ID = "net.janrupf.hytale-dev" + } + + override fun canBuild(modelName: String): Boolean { + return HytaleDevModel::class.java.name == modelName + } + + override fun buildAll(modelName: String, project: Project): Any { + val hasPlugin = project.plugins.hasPlugin(HYTALE_DEV_PLUGIN_ID) + var version: String? = null + + if (hasPlugin) { + // Try to get plugin version from the applied plugin + val appliedPlugin = project.plugins.findPlugin(HYTALE_DEV_PLUGIN_ID) + if (appliedPlugin != null) { + // The plugin class might expose version info, but for now we leave it null + // Future: could extract from plugin metadata + } + } + + return HytaleDevModelImpl(hasPlugin, version) + } + + override fun getErrorMessageBuilder(project: Project, e: Exception): ErrorMessageBuilder { + return ErrorMessageBuilder.create(project, e, "HytaleDocs import errors") + .withDescription("Unable to detect Hytale Dev plugin") + } +} diff --git a/src/gradle-tooling-extension/resources/META-INF/services/org.jetbrains.plugins.gradle.tooling.ModelBuilderService b/src/gradle-tooling-extension/resources/META-INF/services/org.jetbrains.plugins.gradle.tooling.ModelBuilderService new file mode 100644 index 0000000..b49840c --- /dev/null +++ b/src/gradle-tooling-extension/resources/META-INF/services/org.jetbrains.plugins.gradle.tooling.ModelBuilderService @@ -0,0 +1 @@ +com.hytaledocs.intellij.gradle.tooling.HytaleDevModelBuilder diff --git a/src/main/kotlin/com/hytaledocs/intellij/bridge/DevBridgeConnection.kt b/src/main/kotlin/com/hytaledocs/intellij/bridge/DevBridgeConnection.kt new file mode 100644 index 0000000..d07a04d --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/bridge/DevBridgeConnection.kt @@ -0,0 +1,239 @@ +package com.hytaledocs.intellij.bridge + +import com.hytaledocs.intellij.bridge.protocol.HytaleBridgeProto.* +import com.intellij.openapi.diagnostic.Logger +import org.java_websocket.WebSocket +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Handles a single bridge client connection from a Hytale server process. + */ +class DevBridgeConnection( + private val socket: WebSocket, + private val server: DevBridgeServer +) { + companion object { + private val LOG = Logger.getInstance(DevBridgeConnection::class.java) + private const val PROTOCOL_VERSION = 1 + private const val PLUGIN_VERSION = "1.4.0" + } + + private val handshakeComplete = AtomicBoolean(false) + + private var agentCapabilities: List = emptyList() + private var agentVersion: String = "" + private var serverVersion: String = "" + + private val logListeners = CopyOnWriteArrayList<(LogEvent) -> Unit>() + private val serverStateListeners = CopyOnWriteArrayList<(ServerState) -> Unit>() + private val commandRegistryListeners = CopyOnWriteArrayList<(CommandRegistryResponse) -> Unit>() + private val suggestionListeners = CopyOnWriteArrayList<(SuggestionsResponse) -> Unit>() + private val translateListeners = CopyOnWriteArrayList<(TranslateResponse) -> Unit>() + + fun handleMessage(message: AgentMessage) { + when (message.payloadCase) { + AgentMessage.PayloadCase.HELLO -> handleHello(message.hello) + AgentMessage.PayloadCase.LOG_EVENT -> handleLogEvent(message.logEvent) + AgentMessage.PayloadCase.SERVER_STATE -> handleServerState(message.serverState) + AgentMessage.PayloadCase.COMMAND_REGISTRY -> handleCommandRegistry(message.commandRegistry) + AgentMessage.PayloadCase.SUGGESTIONS -> handleSuggestions(message.suggestions) + AgentMessage.PayloadCase.ASSET_PATHS -> handleAssetPaths(message.assetPaths) + AgentMessage.PayloadCase.TRANSLATE_RESPONSE -> handleTranslateResponse(message.translateResponse) + else -> LOG.warn("Unknown message type: ${message.payloadCase}") + } + } + + private fun handleHello(hello: AgentHello) { + LOG.info("Agent hello: version=${hello.agentVersion}, protocol=${hello.protocolVersion}, capabilities=${hello.capabilitiesList}") + + agentVersion = hello.agentVersion + agentCapabilities = hello.capabilitiesList.toList() + serverVersion = hello.serverVersion + + // Respond with IdeHello + val response = IdeHello.newBuilder() + .setProtocolVersion(PROTOCOL_VERSION) + .setPluginVersion(PLUGIN_VERSION) + .addRequestedCapabilities("logs") + .addRequestedCapabilities("commands") + .build() + + val message = IdeMessage.newBuilder() + .setHello(response) + .build() + + socket.send(message.toByteArray()) + handshakeComplete.set(true) + + LOG.info("Handshake complete with agent $agentVersion") + server.notifyConnectionEstablished(this) + } + + private fun handleLogEvent(event: LogEvent) { + if (!handshakeComplete.get()) { + LOG.warn("Received log event before handshake") + return + } + logListeners.forEach { it(event) } + } + + private fun handleServerState(event: ServerStateEvent) { + if (!handshakeComplete.get()) return + serverStateListeners.forEach { it(event.state) } + } + + private fun handleCommandRegistry(registry: CommandRegistryResponse) { + if (!handshakeComplete.get()) return + LOG.info("Received command registry with ${registry.commandsCount} commands") + commandRegistryListeners.forEach { it(registry) } + } + + private fun handleSuggestions(suggestions: SuggestionsResponse) { + if (!handshakeComplete.get()) return + LOG.debug("Received ${suggestions.suggestionsCount} suggestions") + suggestionListeners.forEach { it(suggestions) } + } + + private fun handleAssetPaths(paths: AssetPathsEvent) { + if (!handshakeComplete.get()) return + LOG.info("Received ${paths.pathsCount} asset paths from bridge") + val assetScanner = com.hytaledocs.intellij.services.AssetScannerService.getInstance(server.getProject()) + assetScanner.setBridgeAssetPaths(paths.pathsList) + } + + private fun handleTranslateResponse(response: TranslateResponse) { + if (!handshakeComplete.get()) return + LOG.debug("Received ${response.translationsCount} translations") + translateListeners.forEach { it(response) } + } + + /** + * Request the full command registry from the bridge. + */ + fun requestCommands() { + if (!handshakeComplete.get()) return + + val request = GetCommandsRequest.newBuilder().build() + val message = IdeMessage.newBuilder() + .setGetCommands(request) + .build() + + socket.send(message.toByteArray()) + } + + /** + * Request command suggestions for the given partial command. + */ + fun requestSuggestions(partialCommand: String, cursorPosition: Int) { + if (!handshakeComplete.get()) return + + val request = GetSuggestionsRequest.newBuilder() + .setPartialCommand(partialCommand) + .setCursorPosition(cursorPosition) + .build() + + val message = IdeMessage.newBuilder() + .setGetSuggestions(request) + .build() + + socket.send(message.toByteArray()) + } + + /** + * Execute a command on the server. + */ + fun executeCommand(command: String) { + if (!handshakeComplete.get()) return + + val request = ExecuteCommandRequest.newBuilder() + .setCommand(command) + .build() + + val message = IdeMessage.newBuilder() + .setExecuteCommand(request) + .build() + + socket.send(message.toByteArray()) + } + + /** + * Request translation of keys from the server. + * @param keys List of translation keys to resolve + * @param language Optional language code (defaults to "en" on server) + */ + fun requestTranslation(keys: List, language: String? = null) { + if (!handshakeComplete.get()) return + + val requestBuilder = TranslateRequest.newBuilder() + .addAllKeys(keys) + + if (language != null) { + requestBuilder.language = language + } + + val message = IdeMessage.newBuilder() + .setTranslate(requestBuilder.build()) + .build() + + socket.send(message.toByteArray()) + } + + fun addLogListener(listener: (LogEvent) -> Unit) { + logListeners.add(listener) + } + + fun removeLogListener(listener: (LogEvent) -> Unit) { + logListeners.remove(listener) + } + + fun addServerStateListener(listener: (ServerState) -> Unit) { + serverStateListeners.add(listener) + } + + fun removeServerStateListener(listener: (ServerState) -> Unit) { + serverStateListeners.remove(listener) + } + + fun addCommandRegistryListener(listener: (CommandRegistryResponse) -> Unit) { + commandRegistryListeners.add(listener) + } + + fun removeCommandRegistryListener(listener: (CommandRegistryResponse) -> Unit) { + commandRegistryListeners.remove(listener) + } + + fun addSuggestionListener(listener: (SuggestionsResponse) -> Unit) { + suggestionListeners.add(listener) + } + + fun removeSuggestionListener(listener: (SuggestionsResponse) -> Unit) { + suggestionListeners.remove(listener) + } + + fun addTranslateListener(listener: (TranslateResponse) -> Unit) { + translateListeners.add(listener) + } + + fun removeTranslateListener(listener: (TranslateResponse) -> Unit) { + translateListeners.remove(listener) + } + + fun isHandshakeComplete(): Boolean = handshakeComplete.get() + + fun getAgentVersion(): String = agentVersion + + fun getServerVersion(): String = serverVersion + + fun getCapabilities(): List = agentCapabilities + + fun close() { + if (socket.isOpen) { + socket.close() + } + } + + fun onDisconnect() { + handshakeComplete.set(false) + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/bridge/DevBridgeServer.kt b/src/main/kotlin/com/hytaledocs/intellij/bridge/DevBridgeServer.kt new file mode 100644 index 0000000..08feef8 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/bridge/DevBridgeServer.kt @@ -0,0 +1,214 @@ +package com.hytaledocs.intellij.bridge + +import com.hytaledocs.intellij.bridge.protocol.HytaleBridgeProto.AgentMessage +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.nio.ByteBuffer +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean + +/** + * WebSocket server that accepts connections from the Hytale Dev Bridge running + * in the server process. Handles structured log forwarding, command registry + * exchange, and asset path synchronization. + */ +@Service(Service.Level.PROJECT) +class DevBridgeServer(private val project: Project) : Disposable { + + companion object { + private val LOG = Logger.getInstance(DevBridgeServer::class.java) + private const val PATH = "/hytale-dev-bridge" + + fun getInstance(project: Project): DevBridgeServer { + return project.getService(DevBridgeServer::class.java) + } + } + + private var server: BridgeWebSocketServer? = null + private val isRunning = AtomicBoolean(false) + + private var _port: Int = 0 + private var _authToken: String = "" + + val port: Int get() = _port + val authToken: String get() = _authToken + + fun getProject(): Project = project + + private val connections = ConcurrentHashMap() + private val connectionListeners = CopyOnWriteArrayList() + + /** + * Listener for bridge connection events. + */ + interface ConnectionListener { + fun onConnectionEstablished(connection: DevBridgeConnection) + fun onConnectionClosed(connection: DevBridgeConnection) + } + + /** + * Ensures the bridge server is started. Idempotent - safe to call multiple times. + * @return true if the server is running after this call + */ + @Synchronized + fun ensureStarted(): Boolean { + if (isRunning.get()) return true + + return try { + // Allocate port atomically using ServerSocket(0) + val tempSocket = ServerSocket(0) + _port = tempSocket.localPort + tempSocket.close() + + // Generate secure random token (64 hex characters = 256 bits) + _authToken = generateToken() + + // Start WebSocket server on localhost only + server = BridgeWebSocketServer(InetSocketAddress("127.0.0.1", _port)) + server?.start() + + isRunning.set(true) + LOG.info("DevBridgeServer started on port $_port") + true + } catch (e: Exception) { + LOG.error("Failed to start DevBridgeServer", e) + false + } + } + + private fun generateToken(): String { + val random = SecureRandom() + val bytes = ByteArray(32) + random.nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + fun addConnectionListener(listener: ConnectionListener) { + connectionListeners.add(listener) + } + + fun removeConnectionListener(listener: ConnectionListener) { + connectionListeners.remove(listener) + } + + /** + * Get the first active connection, if any. + */ + fun getActiveConnection(): DevBridgeConnection? { + return connections.values.firstOrNull { it.isHandshakeComplete() } + } + + /** + * Check if there is an active bridge connection. + */ + fun isConnected(): Boolean { + return connections.values.any { it.isHandshakeComplete() } + } + + override fun dispose() { + isRunning.set(false) + + connections.values.forEach { it.close() } + connections.clear() + + try { + server?.stop(1000) + } catch (e: Exception) { + LOG.warn("Error stopping WebSocket server", e) + } + server = null + + LOG.info("DevBridgeServer stopped") + } + + /** + * Inner WebSocket server implementation. + */ + private inner class BridgeWebSocketServer( + address: InetSocketAddress + ) : WebSocketServer(address) { + + override fun onOpen(conn: WebSocket, handshake: ClientHandshake) { + // Validate path + val resourceDesc = handshake.resourceDescriptor + if (!resourceDesc.startsWith(PATH)) { + LOG.warn("Rejected connection: invalid path $resourceDesc") + conn.close(4000, "Invalid path") + return + } + + // Validate auth token from Authorization header + val authHeader = handshake.getFieldValue("Authorization") + if (authHeader.isNullOrBlank()) { + LOG.warn("Rejected connection: missing Authorization header") + conn.close(4001, "Missing authorization") + return + } + + val token = authHeader.removePrefix("Bearer ").trim() + if (token != _authToken) { + LOG.warn("Rejected connection: invalid token") + conn.close(4002, "Invalid token") + return + } + + // Create connection handler + val bridgeConnection = DevBridgeConnection(conn, this@DevBridgeServer) + this@DevBridgeServer.connections[conn] = bridgeConnection + + LOG.info("Bridge client connected from ${conn.remoteSocketAddress}") + } + + override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) { + val bridgeConnection = this@DevBridgeServer.connections.remove(conn) + if (bridgeConnection != null) { + bridgeConnection.onDisconnect() + this@DevBridgeServer.connectionListeners.forEach { it.onConnectionClosed(bridgeConnection) } + } + LOG.info("Bridge client disconnected: $reason (code=$code)") + } + + override fun onMessage(conn: WebSocket, message: String) { + // Text messages not used - protocol is binary protobuf + LOG.debug("Ignoring text message from bridge client") + } + + override fun onMessage(conn: WebSocket, bytes: ByteBuffer) { + val bridgeConnection = this@DevBridgeServer.connections[conn] ?: return + + try { + val data = ByteArray(bytes.remaining()) + bytes.get(data) + val agentMessage = AgentMessage.parseFrom(data) + bridgeConnection.handleMessage(agentMessage) + } catch (e: Exception) { + LOG.error("Failed to parse AgentMessage", e) + } + } + + override fun onError(conn: WebSocket?, ex: Exception) { + if (conn != null) { + LOG.warn("WebSocket error on connection ${conn.remoteSocketAddress}", ex) + } else { + LOG.error("WebSocket server error", ex) + } + } + + override fun onStart() { + LOG.info("BridgeWebSocketServer listening on port $port") + } + } + + internal fun notifyConnectionEstablished(connection: DevBridgeConnection) { + connectionListeners.forEach { it.onConnectionEstablished(connection) } + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/gradle/HytaleDevData.kt b/src/main/kotlin/com/hytaledocs/intellij/gradle/HytaleDevData.kt new file mode 100644 index 0000000..4924421 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/gradle/HytaleDevData.kt @@ -0,0 +1,27 @@ +package com.hytaledocs.intellij.gradle + +import com.intellij.openapi.externalSystem.model.Key +import com.intellij.openapi.externalSystem.model.ProjectKeys +import com.intellij.openapi.externalSystem.model.project.AbstractExternalEntityData +import com.intellij.openapi.externalSystem.model.project.ModuleData + +/** + * Data class stored in the ExternalSystem DataNode when the hytale-dev Gradle plugin is detected. + * This allows quick lookup of whether a module uses the Hytale Dev plugin without re-querying Gradle. + */ +data class HytaleDevData( + val module: ModuleData, + val pluginVersion: String? +) : AbstractExternalEntityData(module.owner) { + + companion object { + /** + * Key for storing HytaleDevData in the DataNode tree. + * Processing weight is set higher than TASK to ensure it's processed after basic module data. + */ + val KEY: Key = Key.create( + HytaleDevData::class.java, + ProjectKeys.TASK.processingWeight + 1 + ) + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/gradle/HytaleDevProjectResolverExtension.kt b/src/main/kotlin/com/hytaledocs/intellij/gradle/HytaleDevProjectResolverExtension.kt new file mode 100644 index 0000000..a3e79bf --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/gradle/HytaleDevProjectResolverExtension.kt @@ -0,0 +1,35 @@ +package com.hytaledocs.intellij.gradle + +import com.hytaledocs.intellij.gradle.tooling.HytaleDevModel +import com.intellij.openapi.externalSystem.model.DataNode +import com.intellij.openapi.externalSystem.model.project.ModuleData +import org.gradle.tooling.model.idea.IdeaModule +import org.jetbrains.plugins.gradle.service.project.AbstractProjectResolverExtension + +/** + * Gradle ProjectResolverExtension that queries the HytaleDevModel during Gradle sync. + * When the hytale-dev plugin is detected, it creates a HytaleDevData child node on the module. + */ +class HytaleDevProjectResolverExtension : AbstractProjectResolverExtension() { + + override fun getExtraProjectModelClasses(): Set> { + return setOf(HytaleDevModel::class.java) + } + + override fun getToolingExtensionsClasses(): Set> { + return extraProjectModelClasses + } + + override fun populateModuleExtraModels(gradleModule: IdeaModule, ideModule: DataNode) { + val model = resolverCtx.getExtraProject(gradleModule, HytaleDevModel::class.java) + + if (model != null && model.hasHytaleDevPlugin()) { + ideModule.createChild( + HytaleDevData.KEY, + HytaleDevData(ideModule.data, model.pluginVersion) + ) + } + + super.populateModuleExtraModels(gradleModule, ideModule) + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/gradle/HytaleGradleSyncListener.kt b/src/main/kotlin/com/hytaledocs/intellij/gradle/HytaleGradleSyncListener.kt new file mode 100644 index 0000000..c7cc2c7 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/gradle/HytaleGradleSyncListener.kt @@ -0,0 +1,63 @@ +package com.hytaledocs.intellij.gradle + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.externalSystem.service.project.manage.ProjectDataImportListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.openapi.wm.ToolWindowManager + +/** + * Startup activity that subscribes to Gradle sync completion events + * and notifies the tool window to refresh its content. + */ +class HytaleGradleSyncStartupActivity : ProjectActivity { + + companion object { + private val LOG = Logger.getInstance(HytaleGradleSyncStartupActivity::class.java) + } + + override suspend fun execute(project: Project) { + // Subscribe to project data import events via message bus + val connection = project.messageBus.connect() + + connection.subscribe( + ProjectDataImportListener.TOPIC, + object : ProjectDataImportListener { + override fun onImportFinished(projectPath: String?) { + LOG.info("External system import finished for project: ${project.name}") + + // Notify the tool window to refresh (on EDT) + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + refreshToolWindow(project) + } + } + } + ) + } + + private fun refreshToolWindow(project: Project) { + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("HytaleDocs") + if (toolWindow != null) { + // Get the content and check if it's our panel + val content = toolWindow.contentManager.getContent(0) + val component = content?.component + if (component is HytaleToolWindowRefreshable) { + LOG.info("Refreshing Hytale tool window after Gradle sync") + component.onProjectTypeChanged() + } + } + } +} + +/** + * Interface for tool window panels that can be refreshed when project type changes. + */ +interface HytaleToolWindowRefreshable { + /** + * Called when the project type detection may have changed (e.g., after Gradle sync). + * Implementations should refresh their content accordingly. + */ + fun onProjectTypeChanged() +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleRunConfigurationExtension.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleRunConfigurationExtension.kt new file mode 100644 index 0000000..9845055 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleRunConfigurationExtension.kt @@ -0,0 +1,78 @@ +package com.hytaledocs.intellij.run + +import com.hytaledocs.intellij.bridge.DevBridgeServer +import com.intellij.execution.RunConfigurationExtension +import com.intellij.execution.configurations.JavaParameters +import com.intellij.execution.configurations.RunConfigurationBase +import com.intellij.execution.configurations.RunnerSettings +import com.intellij.openapi.diagnostic.Logger + +/** + * Run configuration extension that injects Dev Bridge environment variables + * into Hytale server processes. + * + * Applies to: + * 1. JarApplication configs from Gradle plugin (detected by HYTALE_DEV_AGENT_CONFIGURATION env var) + * 2. Legacy HytaleServerRunConfiguration + */ +class HytaleRunConfigurationExtension : RunConfigurationExtension() { + + companion object { + private val LOG = Logger.getInstance(HytaleRunConfigurationExtension::class.java) + private const val AGENT_CONFIG_ENV_VAR = "HYTALE_DEV_AGENT_CONFIGURATION" + private const val BRIDGE_PORT_ENV_VAR = "HYTALE_DEV_BRIDGE_PORT" + private const val BRIDGE_TOKEN_ENV_VAR = "HYTALE_DEV_BRIDGE_TOKEN" + } + + override fun isApplicableFor(configuration: RunConfigurationBase<*>): Boolean { + // Apply to legacy HytaleServerRunConfiguration + if (configuration is HytaleServerRunConfiguration) { + return true + } + + // For JarApplication configs, we check for the agent env var in updateJavaParameters + val typeName = configuration.type.id + return typeName == "JarApplication" || typeName == "Application" + } + + override fun ?> updateJavaParameters( + configuration: T & Any, + params: JavaParameters, + runnerSettings: RunnerSettings? + ) { + val project = configuration.project + val env = params.env + + // Check if this is a Gradle-based Hytale run + val isGradleHytaleRun = env.containsKey(AGENT_CONFIG_ENV_VAR) + + // Check if this is a legacy HytaleServerRunConfiguration + val isLegacyHytaleRun = configuration is HytaleServerRunConfiguration + + if (!isGradleHytaleRun && !isLegacyHytaleRun) { + // Not a Hytale run, do not inject + return + } + + // Skip if bridge env vars are already set + if (env.containsKey(BRIDGE_PORT_ENV_VAR)) { + LOG.debug("Bridge env vars already set, skipping injection") + return + } + + // Start bridge server if not running + val bridgeServer = DevBridgeServer.getInstance(project) + if (!bridgeServer.ensureStarted()) { + LOG.warn("Failed to start DevBridgeServer, skipping env var injection") + return + } + + // Inject environment variables + env[BRIDGE_PORT_ENV_VAR] = bridgeServer.port.toString() + env[BRIDGE_TOKEN_ENV_VAR] = bridgeServer.authToken + + LOG.info("Injected bridge env vars: port=${bridgeServer.port}") + } + + override fun getSerializationId(): String = "hytale-bridge" +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleRunConfigurationSetup.kt b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleRunConfigurationSetup.kt index f7beeb2..9a94db3 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/run/HytaleRunConfigurationSetup.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/run/HytaleRunConfigurationSetup.kt @@ -1,5 +1,7 @@ package com.hytaledocs.intellij.run +import com.hytaledocs.intellij.services.HytaleProjectService +import com.hytaledocs.intellij.services.HytaleProjectType import com.hytaledocs.intellij.util.PluginInfoDetector import com.intellij.execution.RunManager import com.intellij.openapi.diagnostic.Logger @@ -9,7 +11,10 @@ import java.io.File /** * Startup activity that creates a default Hytale Server run configuration - * if one doesn't exist and the project appears to be a Hytale plugin project. + * if one doesn't exist and the project appears to be a legacy Hytale plugin project. + * + * Note: This skips Gradle plugin projects (net.janrupf.hytale-dev) as they use + * Gradle-generated JarApplication run configurations instead. */ class HytaleRunConfigurationSetup : ProjectActivity { @@ -18,12 +23,23 @@ class HytaleRunConfigurationSetup : ProjectActivity { } override suspend fun execute(project: Project) { - // Check if this looks like a Hytale plugin project - if (!isHytaleProject(project)) { + val projectService = HytaleProjectService.getInstance(project) + val projectType = projectService.detectProjectType() + + // Skip if not a Hytale project + if (projectType == HytaleProjectType.UNKNOWN) { LOG.info("Not a Hytale project, skipping run configuration setup") return } + // Skip Gradle plugin projects - they use Gradle-generated JarApplication configs + if (projectType == HytaleProjectType.GRADLE_PLUGIN) { + LOG.info("Gradle plugin project detected (net.janrupf.hytale-dev), skipping legacy run configuration setup") + return + } + + LOG.info("Legacy Hytale project detected ($projectType), setting up run configuration") + // Check if we already have a Hytale Server run configuration val runManager = RunManager.getInstance(project) val existingSettings = runManager.allSettings @@ -65,31 +81,6 @@ class HytaleRunConfigurationSetup : ProjectActivity { createHytaleServerRunConfiguration(project, pluginInfo) } - private fun isHytaleProject(project: Project): Boolean { - val basePath = project.basePath ?: return false - - // Check for Hytale-specific indicators - val indicators = listOf( - File(basePath, ".hytale/project.json"), - File(basePath, "server/HytaleServer.jar"), - File(basePath, "libs/HytaleServer.jar"), - File(basePath, "src/main/resources/manifest.json") - ) - - val hasIndicator = indicators.any { it.exists() } - - // Also check for Hytale dependency in build.gradle - val buildGradle = File(basePath, "build.gradle") - val buildGradleKts = File(basePath, "build.gradle.kts") - val hasHytaleDep = when { - buildGradle.exists() -> buildGradle.readText().contains("HytaleServer") - buildGradleKts.exists() -> buildGradleKts.readText().contains("HytaleServer") - else -> false - } - - return hasIndicator || hasHytaleDep - } - /** * Detects plugin info from project files using the shared utility. */ diff --git a/src/main/kotlin/com/hytaledocs/intellij/services/AssetScannerService.kt b/src/main/kotlin/com/hytaledocs/intellij/services/AssetScannerService.kt index ef91f26..8e8cb39 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/services/AssetScannerService.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/services/AssetScannerService.kt @@ -59,6 +59,10 @@ class AssetScannerService(private val project: Project) { @Volatile private var cachedStats: AssetStats = AssetStats.EMPTY + // Asset paths received from dev bridge (plugin resource directories) + @Volatile + private var bridgeAssetPaths: MutableList = mutableListOf() + /** * Data class to hold both tree views and stats from a single scan operation. */ @@ -175,30 +179,77 @@ class AssetScannerService(private val project: Project) { cachedStats = AssetStats.EMPTY } + /** + * Set asset paths received from the dev bridge. + * These are absolute paths to plugin resource directories on the running server. + */ + fun setBridgeAssetPaths(paths: List) { + bridgeAssetPaths = paths.toMutableList() + LOG.info("Received ${paths.size} asset paths from bridge") + clearCache() + } + + /** + * Clear asset paths from the dev bridge (called on disconnect). + */ + fun clearBridgeAssetPaths() { + if (bridgeAssetPaths.isNotEmpty()) { + bridgeAssetPaths.clear() + LOG.info("Cleared bridge asset paths") + clearCache() + } + } + /** * Perform a synchronous scan of assets. Called by the CachedValue provider. */ @RequiresReadLock private fun performSynchronousScan(): ScanResult? { - val resourcesDir = getResourcesDirectory() ?: return null + val resourcesDir = getResourcesDirectory() + val hasBridgePaths = bridgeAssetPaths.isNotEmpty() + + // Need at least one source to scan + if (resourcesDir == null && !hasBridgePaths) { + return null + } val allFiles = mutableListOf() - // Collect all asset files from directory - collectAssetFiles(resourcesDir, resourcesDir, allFiles, null) + // Collect all asset files from project resources directory + if (resourcesDir != null) { + collectAssetFiles(resourcesDir, resourcesDir, allFiles, null) + + // Scan ZIP files for assets + val zipFiles = findAssetZipFiles(resourcesDir) + for (zipFile in zipFiles) { + collectAssetsFromZip(zipFile, allFiles, null) + } + } - // Scan ZIP files for assets - val zipFiles = findAssetZipFiles(resourcesDir) - for (zipFile in zipFiles) { - collectAssetsFromZip(zipFile, allFiles, null) + // Collect assets from bridge paths (plugin resource directories from running server) + for (bridgePath in bridgeAssetPaths) { + val bridgeDir = LocalFileSystem.getInstance().findFileByPath(bridgePath) + if (bridgeDir != null && bridgeDir.isDirectory) { + LOG.info("Scanning bridge asset path: $bridgePath") + collectAssetFiles(bridgeDir, bridgeDir, allFiles, null) + } else { + LOG.warn("Bridge asset path not accessible: $bridgePath") + } } // Build both trees val byTypeRoot = AssetNode.RootNode() val byFolderRoot = AssetNode.RootNode() + // Use a dummy base dir for folder tree if no resources dir + val baseDir = resourcesDir ?: bridgeAssetPaths.firstOrNull()?.let { + LocalFileSystem.getInstance().findFileByPath(it) + } + buildByTypeTree(byTypeRoot, allFiles) - buildByFolderTree(byFolderRoot, allFiles, resourcesDir) + if (baseDir != null) { + buildByFolderTree(byFolderRoot, allFiles, baseDir) + } // Calculate statistics val byType = allFiles.groupBy { it.assetType }.mapValues { it.value.size } @@ -208,7 +259,10 @@ class AssetScannerService(private val project: Project) { byType = byType ) - LOG.info("Scanned ${allFiles.size} assets in ${resourcesDir.path}") + val sources = mutableListOf() + if (resourcesDir != null) sources.add(resourcesDir.path) + sources.addAll(bridgeAssetPaths) + LOG.info("Scanned ${allFiles.size} assets from ${sources.size} source(s)") return ScanResult(byTypeRoot, byFolderRoot, stats, allFiles) } @@ -229,25 +283,42 @@ class AssetScannerService(private val project: Project) { return CompletableFuture.supplyAsync { try { val resourcesDir = getResourcesDirectory() - if (resourcesDir == null) { - LOG.info("No resources directory found") + val hasBridgePaths = bridgeAssetPaths.isNotEmpty() + + if (resourcesDir == null && !hasBridgePaths) { + LOG.info("No resources directory or bridge paths found") onProgress?.invoke(100, "No resources directory found") return@supplyAsync null } - onProgress?.invoke(0, "Scanning ${resourcesDir.name}...") + onProgress?.invoke(0, "Scanning assets...") val allFiles = mutableListOf() - // Collect all asset files from directory - collectAssetFiles(resourcesDir, resourcesDir, allFiles, onProgress) + // Collect all asset files from project resources directory + if (resourcesDir != null) { + onProgress?.invoke(5, "Scanning ${resourcesDir.name}...") + collectAssetFiles(resourcesDir, resourcesDir, allFiles, onProgress) + + // Scan ZIP files for assets + onProgress?.invoke(30, "Scanning ZIP archives...") + val zipFiles = findAssetZipFiles(resourcesDir) + for (zipFile in zipFiles) { + onProgress?.invoke(40, "Scanning ${zipFile.name}...") + collectAssetsFromZip(zipFile, allFiles, onProgress) + } + } - // Scan ZIP files for assets - onProgress?.invoke(30, "Scanning ZIP archives...") - val zipFiles = findAssetZipFiles(resourcesDir) - for (zipFile in zipFiles) { - onProgress?.invoke(40, "Scanning ${zipFile.name}...") - collectAssetsFromZip(zipFile, allFiles, onProgress) + // Collect assets from bridge paths + if (hasBridgePaths) { + onProgress?.invoke(50, "Scanning bridge paths...") + for (bridgePath in bridgeAssetPaths) { + val bridgeDir = LocalFileSystem.getInstance().findFileByPath(bridgePath) + if (bridgeDir != null && bridgeDir.isDirectory) { + onProgress?.invoke(55, "Scanning ${bridgeDir.name}...") + collectAssetFiles(bridgeDir, bridgeDir, allFiles, onProgress) + } + } } onProgress?.invoke(70, "Building tree...") @@ -256,8 +327,14 @@ class AssetScannerService(private val project: Project) { val byTypeRoot = AssetNode.RootNode() val byFolderRoot = AssetNode.RootNode() + val baseDir = resourcesDir ?: bridgeAssetPaths.firstOrNull()?.let { + LocalFileSystem.getInstance().findFileByPath(it) + } + buildByTypeTree(byTypeRoot, allFiles) - buildByFolderTree(byFolderRoot, allFiles, resourcesDir) + if (baseDir != null) { + buildByFolderTree(byFolderRoot, allFiles, baseDir) + } // Calculate statistics val byType = allFiles.groupBy { it.assetType }.mapValues { it.value.size } @@ -270,7 +347,10 @@ class AssetScannerService(private val project: Project) { onProgress?.invoke(100, "Found ${allFiles.size} assets") - LOG.info("Scanned ${allFiles.size} assets in ${resourcesDir.path}") + val sources = mutableListOf() + if (resourcesDir != null) sources.add(resourcesDir.path) + sources.addAll(bridgeAssetPaths) + LOG.info("Scanned ${allFiles.size} assets from ${sources.size} source(s)") val result = ScanResult(byTypeRoot, byFolderRoot, stats, allFiles) // Force cache refresh by incrementing and then allowing CachedValue to recompute diff --git a/src/main/kotlin/com/hytaledocs/intellij/services/CommandRegistryCache.kt b/src/main/kotlin/com/hytaledocs/intellij/services/CommandRegistryCache.kt new file mode 100644 index 0000000..2602b7d --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/services/CommandRegistryCache.kt @@ -0,0 +1,363 @@ +package com.hytaledocs.intellij.services + +import com.hytaledocs.intellij.bridge.DevBridgeConnection +import com.hytaledocs.intellij.bridge.DevBridgeServer +import com.hytaledocs.intellij.bridge.protocol.HytaleBridgeProto.CommandInfo +import com.hytaledocs.intellij.bridge.protocol.HytaleBridgeProto.CommandRegistryResponse +import com.hytaledocs.intellij.bridge.protocol.HytaleBridgeProto.SuggestionsResponse +import com.hytaledocs.intellij.bridge.protocol.HytaleBridgeProto.TranslateResponse +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.schedule + +/** + * Caches command registry from the dev bridge and provides command completion. + * Manages debounced suggestion requests for efficient autocomplete. + */ +@Service(Service.Level.PROJECT) +class CommandRegistryCache(private val project: Project) : Disposable { + + companion object { + private val LOG = Logger.getInstance(CommandRegistryCache::class.java) + private const val DEBOUNCE_MS = 50L + + fun getInstance(project: Project): CommandRegistryCache { + return project.getService(CommandRegistryCache::class.java) + } + } + + /** + * Represents a command suggestion for the autocomplete popup. + */ + data class CommandSuggestion( + val text: String, + val displayText: String, + val description: String, + val isCommand: Boolean, + val aliases: List = emptyList(), + val argumentType: String? = null + ) + + // Cached command registry from bridge + private val commandRegistry = AtomicReference(null) + + // Pending suggestion callbacks + private val pendingSuggestionCallbacks = ConcurrentHashMap, Int) -> Unit>() + + // Debounce timer + private var suggestionDebounceTimer: Timer? = null + private val timerLock = Object() + + // Registry update listeners + private val registryListeners = CopyOnWriteArrayList<(CommandRegistryResponse) -> Unit>() + + // Translated description cache (translation key -> translated text) + private val translatedDescriptions = ConcurrentHashMap() + + // Active connection + private var activeConnection: DevBridgeConnection? = null + + // Connection listener + private val connectionListener = object : DevBridgeServer.ConnectionListener { + override fun onConnectionEstablished(connection: DevBridgeConnection) { + LOG.info("Bridge connected, requesting command registry") + activeConnection = connection + connection.addCommandRegistryListener(commandRegistryListener) + connection.addSuggestionListener(suggestionListener) + connection.addTranslateListener(translateListener) + // Request command registry + connection.requestCommands() + } + + override fun onConnectionClosed(connection: DevBridgeConnection) { + LOG.info("Bridge disconnected, clearing command registry") + if (activeConnection == connection) { + activeConnection = null + commandRegistry.set(null) + translatedDescriptions.clear() + pendingSuggestionCallbacks.clear() + } + } + } + + // Command registry listener + private val commandRegistryListener: (CommandRegistryResponse) -> Unit = { registry -> + LOG.info("Received command registry with ${registry.commandsCount} commands") + commandRegistry.set(registry) + registryListeners.forEach { listener -> + try { + listener(registry) + } catch (e: Exception) { + LOG.warn("Error in registry listener", e) + } + } + // Request translations for command descriptions + requestDescriptionTranslations(registry) + } + + // Suggestion response listener + private val suggestionListener: (SuggestionsResponse) -> Unit = { response -> + // Find and invoke the pending callback + val iterator = pendingSuggestionCallbacks.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + try { + entry.value(response.suggestionsList, response.startPosition) + } catch (e: Exception) { + LOG.warn("Error in suggestion callback", e) + } + iterator.remove() + break // Only call the first pending callback + } + } + + // Translate response listener + private val translateListener: (TranslateResponse) -> Unit = { response -> + LOG.info("Received ${response.translationsCount} translations") + translatedDescriptions.putAll(response.translationsMap) + // Notify registry listeners so UI can update with translated descriptions + commandRegistry.get()?.let { registry -> + registryListeners.forEach { listener -> + try { + listener(registry) + } catch (e: Exception) { + LOG.warn("Error in registry listener after translation", e) + } + } + } + } + + init { + // Register with DevBridgeServer for connection events + val bridgeServer = DevBridgeServer.getInstance(project) + bridgeServer.addConnectionListener(connectionListener) + + // If already connected, request commands + bridgeServer.getActiveConnection()?.let { connection -> + activeConnection = connection + connection.addCommandRegistryListener(commandRegistryListener) + connection.addSuggestionListener(suggestionListener) + connection.addTranslateListener(translateListener) + connection.requestCommands() + } + } + + /** + * Request translations for all description keys in the registry. + */ + private fun requestDescriptionTranslations(registry: CommandRegistryResponse) { + val keys = mutableSetOf() + collectDescriptionKeys(registry.commandsList, keys) + + if (keys.isEmpty()) return + + // Filter out keys we already have + val newKeys = keys.filter { !translatedDescriptions.containsKey(it) } + if (newKeys.isEmpty()) return + + LOG.info("Requesting ${newKeys.size} translations") + activeConnection?.requestTranslation(newKeys) + } + + /** + * Recursively collect all description keys from commands and subcommands. + */ + private fun collectDescriptionKeys(commands: List, keys: MutableSet) { + for (cmd in commands) { + if (cmd.description.isNotEmpty()) { + keys.add(cmd.description) + } + collectDescriptionKeys(cmd.subCommandsList, keys) + } + } + + /** + * Get translated text for a description key. + * Returns the translated text if available, otherwise the original key. + */ + fun getTranslatedDescription(key: String): String { + return translatedDescriptions[key] ?: key + } + + /** + * Get cached command registry. + */ + fun getCommandRegistry(): CommandRegistryResponse? = commandRegistry.get() + + /** + * Check if command registry is available. + */ + fun hasCommandRegistry(): Boolean = commandRegistry.get() != null + + /** + * Get instant completions from cached registry. + * Used for fast local completions without bridge round-trip. + */ + fun getLocalCompletions(partialCommand: String): List { + val registry = commandRegistry.get() ?: return emptyList() + // Input should already have / stripped, but handle it gracefully + val prefix = partialCommand.removePrefix("/").lowercase() + + if (prefix.isEmpty()) { + // Return all top-level commands (without / prefix) + return registry.commandsList.map { cmd -> + CommandSuggestion( + text = cmd.name, + displayText = cmd.name, + description = getTranslatedDescription(cmd.description), + isCommand = true, + aliases = cmd.aliasesList.toList() + ) + }.sortedBy { it.text } + } + + val parts = prefix.split(" ") + + // Navigate to the appropriate level in the command tree + var currentCommands = registry.commandsList + var consumedParts = 0 + + for (i in parts.indices) { + if (i == parts.lastIndex) break // Don't consume the part we're completing + + val part = parts[i] + val matchedCommand = currentCommands.find { cmd -> + cmd.name == part || part in cmd.aliasesList + } + + if (matchedCommand != null) { + currentCommands = matchedCommand.subCommandsList + consumedParts = i + 1 + } else { + break + } + } + + // Get the partial text we're completing + val completingPart = if (consumedParts < parts.size) parts[consumedParts].lowercase() else "" + + // Find matching commands at this level + val matchingCommands = currentCommands.filter { cmd -> + cmd.name.startsWith(completingPart) || + cmd.aliasesList.any { it.startsWith(completingPart) } + } + + // Build the prefix for suggestions (path of parent commands, no leading /) + val prefixPath = if (consumedParts > 0) { + parts.take(consumedParts).joinToString(" ") + " " + } else { + "" + } + + return matchingCommands.map { cmd -> + CommandSuggestion( + text = "$prefixPath${cmd.name}", + displayText = cmd.name, + description = getTranslatedDescription(cmd.description), + isCommand = true, + aliases = cmd.aliasesList.toList() + ) + }.sortedBy { it.text } + } + + /** + * Request dynamic suggestions from bridge (debounced). + */ + fun requestSuggestions( + partialCommand: String, + cursorPosition: Int, + callback: (List, Int) -> Unit + ) { + val connection = activeConnection ?: run { + callback(emptyList(), 0) + return + } + + // Strip leading slash - it's a UI convention, not part of the command + val cleanCommand = partialCommand.removePrefix("/") + val adjustedCursorPos = if (partialCommand.startsWith("/")) { + (cursorPosition - 1).coerceAtLeast(0) + } else { + cursorPosition + } + + synchronized(timerLock) { + // Cancel previous timer + suggestionDebounceTimer?.cancel() + + // Create new debounced request + suggestionDebounceTimer = Timer().apply { + schedule(DEBOUNCE_MS) { + val requestKey = "$cleanCommand:$adjustedCursorPos:${System.currentTimeMillis()}" + pendingSuggestionCallbacks[requestKey] = callback + connection.requestSuggestions(cleanCommand, adjustedCursorPos) + } + } + } + } + + /** + * Find a command by path (e.g., "give" or "player stats get"). + */ + fun findCommand(commandPath: String): CommandInfo? { + val registry = commandRegistry.get() ?: return null + val parts = commandPath.removePrefix("/").split(" ") + + var current: CommandInfo? = registry.commandsList.find { + it.name == parts[0] || parts[0] in it.aliasesList + } + + for (i in 1 until parts.size) { + current = current?.subCommandsList?.find { + it.name == parts[i] || parts[i] in it.aliasesList + } ?: return null + } + + return current + } + + /** + * Register a callback to receive registry updates. + */ + fun addRegistryListener(listener: (CommandRegistryResponse) -> Unit) { + registryListeners.add(listener) + // Immediately notify with current registry if available + commandRegistry.get()?.let { listener(it) } + } + + /** + * Unregister a registry listener. + */ + fun removeRegistryListener(listener: (CommandRegistryResponse) -> Unit) { + registryListeners.remove(listener) + } + + override fun dispose() { + synchronized(timerLock) { + suggestionDebounceTimer?.cancel() + suggestionDebounceTimer = null + } + + try { + val bridgeServer = DevBridgeServer.getInstance(project) + bridgeServer.removeConnectionListener(connectionListener) + } catch (e: Exception) { + LOG.warn("Error unregistering from bridge server", e) + } + + activeConnection?.removeCommandRegistryListener(commandRegistryListener) + activeConnection?.removeSuggestionListener(suggestionListener) + activeConnection?.removeTranslateListener(translateListener) + activeConnection = null + + pendingSuggestionCallbacks.clear() + translatedDescriptions.clear() + registryListeners.clear() + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/services/ConsoleLogService.kt b/src/main/kotlin/com/hytaledocs/intellij/services/ConsoleLogService.kt new file mode 100644 index 0000000..e0371a3 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/services/ConsoleLogService.kt @@ -0,0 +1,295 @@ +package com.hytaledocs.intellij.services + +import com.hytaledocs.intellij.bridge.DevBridgeConnection +import com.hytaledocs.intellij.bridge.DevBridgeServer +import com.hytaledocs.intellij.bridge.protocol.HytaleBridgeProto.LogEvent +import com.hytaledocs.intellij.bridge.protocol.HytaleBridgeProto.LogLevel +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference + +/** + * Mediates between log sources (stdout parsing and bridge events) and the console UI. + * Manages mode switching and provides a unified log event interface. + */ +@Service(Service.Level.PROJECT) +class ConsoleLogService(private val project: Project) : Disposable { + + companion object { + private val LOG = Logger.getInstance(ConsoleLogService::class.java) + private val FALLBACK_LOG_REGEX = Regex("""^\[[\d/]+\s+[\d:]+\s+(\w+)\]\s+(.*)$""") + + fun getInstance(project: Project): ConsoleLogService { + return project.getService(ConsoleLogService::class.java) + } + } + + /** + * Console log mode - fallback (stdout parsing) or bridge (structured events). + */ + enum class ConsoleLogMode { + FALLBACK_PARSING, + BRIDGE_CONNECTED + } + + /** + * Log level for unified events. + */ + enum class UnifiedLogLevel { + UNKNOWN, + TRACE, + DEBUG, + INFO, + WARNING, + ERROR, + FATAL + } + + /** + * Unified log event that can come from either source. + */ + data class UnifiedLogEvent( + val timestamp: Long, + val level: UnifiedLogLevel, + val loggerName: String?, + val message: String, + val throwable: String?, + val threadName: String?, + val isSystemMessage: Boolean = false + ) + + // Current mode + private val currentMode = AtomicReference(ConsoleLogMode.FALLBACK_PARSING) + + // Active bridge connection (when in BRIDGE_CONNECTED mode) + private var activeConnection: DevBridgeConnection? = null + + // Callbacks + private val logCallbacks = CopyOnWriteArrayList<(UnifiedLogEvent) -> Unit>() + private val modeCallbacks = CopyOnWriteArrayList<(ConsoleLogMode) -> Unit>() + + // Bridge connection listener + private val connectionListener = object : DevBridgeServer.ConnectionListener { + override fun onConnectionEstablished(connection: DevBridgeConnection) { + LOG.info("Bridge connection established, switching to BRIDGE_CONNECTED mode") + activeConnection = connection + connection.addLogListener(bridgeLogListener) + setMode(ConsoleLogMode.BRIDGE_CONNECTED) + } + + override fun onConnectionClosed(connection: DevBridgeConnection) { + LOG.info("Bridge connection closed, switching to FALLBACK_PARSING mode") + if (activeConnection == connection) { + activeConnection = null + setMode(ConsoleLogMode.FALLBACK_PARSING) + // Clear bridge asset paths since the connection is gone + AssetScannerService.getInstance(project).clearBridgeAssetPaths() + } + } + } + + // Bridge log listener + private val bridgeLogListener: (LogEvent) -> Unit = { event -> + val unifiedEvent = UnifiedLogEvent( + timestamp = event.timestamp, + level = convertLogLevel(event.level), + loggerName = event.loggerName.takeIf { it.isNotEmpty() }, + message = event.message, + throwable = event.throwable.takeIf { it.isNotEmpty() }, + threadName = event.threadName.takeIf { it.isNotEmpty() }, + isSystemMessage = false + ) + fireLogEvent(unifiedEvent) + } + + init { + // Register with DevBridgeServer for connection events + val bridgeServer = DevBridgeServer.getInstance(project) + bridgeServer.addConnectionListener(connectionListener) + + // If there's already an active connection, switch to bridge mode + bridgeServer.getActiveConnection()?.let { connection -> + activeConnection = connection + connection.addLogListener(bridgeLogListener) + currentMode.set(ConsoleLogMode.BRIDGE_CONNECTED) + } + } + + /** + * Get current console log mode. + */ + fun getMode(): ConsoleLogMode = currentMode.get() + + /** + * Check if bridge is currently connected. + */ + fun isBridgeConnected(): Boolean = currentMode.get() == ConsoleLogMode.BRIDGE_CONNECTED + + /** + * Get active bridge connection, if any. + */ + fun getActiveConnection(): DevBridgeConnection? = activeConnection + + /** + * Register a callback to receive log events. + */ + fun registerLogCallback(callback: (UnifiedLogEvent) -> Unit) { + logCallbacks.add(callback) + } + + /** + * Unregister a log callback. + */ + fun unregisterLogCallback(callback: (UnifiedLogEvent) -> Unit) { + logCallbacks.remove(callback) + } + + /** + * Register a callback to receive mode change notifications. + */ + fun registerModeCallback(callback: (ConsoleLogMode) -> Unit) { + modeCallbacks.add(callback) + } + + /** + * Unregister a mode callback. + */ + fun unregisterModeCallback(callback: (ConsoleLogMode) -> Unit) { + modeCallbacks.remove(callback) + } + + /** + * Process a fallback log line from stdout. + * Parses the line and fires a unified log event. + */ + fun onFallbackLog(line: String) { + // Skip if we're in bridge mode (bridge provides structured logs) + if (currentMode.get() == ConsoleLogMode.BRIDGE_CONNECTED) { + return + } + + val event = parseFallbackLog(line) + fireLogEvent(event) + } + + /** + * Log a system message (not from the server, but from the IDE). + */ + fun logSystemMessage(message: String) { + val event = UnifiedLogEvent( + timestamp = System.currentTimeMillis(), + level = UnifiedLogLevel.INFO, + loggerName = null, + message = message, + throwable = null, + threadName = null, + isSystemMessage = true + ) + fireLogEvent(event) + } + + /** + * Reset the service state (e.g., when server stops). + */ + fun reset() { + // Note: we don't change mode here - the bridge connection may still be active + // Mode will change when the connection actually closes + } + + private fun setMode(mode: ConsoleLogMode) { + val previous = currentMode.getAndSet(mode) + if (previous != mode) { + LOG.info("Console log mode changed: $previous -> $mode") + modeCallbacks.forEach { callback -> + try { + callback(mode) + } catch (e: Exception) { + LOG.warn("Error in mode callback", e) + } + } + } + } + + private fun fireLogEvent(event: UnifiedLogEvent) { + logCallbacks.forEach { callback -> + try { + callback(event) + } catch (e: Exception) { + LOG.warn("Error in log callback", e) + } + } + } + + private fun parseFallbackLog(line: String): UnifiedLogEvent { + val match = FALLBACK_LOG_REGEX.find(line) + return if (match != null) { + val levelStr = match.groupValues[1] + val message = match.groupValues[2].trim() + UnifiedLogEvent( + timestamp = System.currentTimeMillis(), + level = parseLogLevel(levelStr), + loggerName = null, + message = message, + throwable = null, + threadName = null, + isSystemMessage = false + ) + } else { + // Line doesn't match expected format, treat as INFO + UnifiedLogEvent( + timestamp = System.currentTimeMillis(), + level = UnifiedLogLevel.INFO, + loggerName = null, + message = line, + throwable = null, + threadName = null, + isSystemMessage = false + ) + } + } + + private fun parseLogLevel(level: String): UnifiedLogLevel { + return when (level.uppercase()) { + "TRACE" -> UnifiedLogLevel.TRACE + "DEBUG", "FINE", "FINER", "FINEST" -> UnifiedLogLevel.DEBUG + "INFO" -> UnifiedLogLevel.INFO + "WARN", "WARNING" -> UnifiedLogLevel.WARNING + "ERROR", "SEVERE" -> UnifiedLogLevel.ERROR + "FATAL" -> UnifiedLogLevel.FATAL + else -> UnifiedLogLevel.INFO + } + } + + private fun convertLogLevel(protoLevel: LogLevel): UnifiedLogLevel { + return when (protoLevel) { + LogLevel.LOG_LEVEL_TRACE -> UnifiedLogLevel.TRACE + LogLevel.LOG_LEVEL_DEBUG -> UnifiedLogLevel.DEBUG + LogLevel.LOG_LEVEL_INFO -> UnifiedLogLevel.INFO + LogLevel.LOG_LEVEL_WARNING -> UnifiedLogLevel.WARNING + LogLevel.LOG_LEVEL_ERROR -> UnifiedLogLevel.ERROR + LogLevel.LOG_LEVEL_FATAL -> UnifiedLogLevel.FATAL + else -> UnifiedLogLevel.UNKNOWN + } + } + + override fun dispose() { + // Unregister from bridge server + try { + val bridgeServer = DevBridgeServer.getInstance(project) + bridgeServer.removeConnectionListener(connectionListener) + } catch (e: Exception) { + LOG.warn("Error unregistering from bridge server", e) + } + + // Remove log listener from active connection + activeConnection?.removeLogListener(bridgeLogListener) + activeConnection = null + + // Clear callbacks + logCallbacks.clear() + modeCallbacks.clear() + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/services/HytaleProjectService.kt b/src/main/kotlin/com/hytaledocs/intellij/services/HytaleProjectService.kt index d289b4d..569c37b 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/services/HytaleProjectService.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/services/HytaleProjectService.kt @@ -1,8 +1,13 @@ package com.hytaledocs.intellij.services +import com.hytaledocs.intellij.gradle.HytaleDevData import com.hytaledocs.intellij.util.HttpClientPool import com.intellij.openapi.components.Service +import com.intellij.openapi.externalSystem.model.DataNode +import com.intellij.openapi.externalSystem.model.project.ModuleData +import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.Project +import org.jetbrains.plugins.gradle.util.GradleUtil import java.io.File import java.net.URI import java.net.http.HttpRequest @@ -11,6 +16,20 @@ import java.nio.file.Path import java.nio.file.StandardCopyOption import java.util.concurrent.CompletableFuture +/** + * Represents the type of Hytale project detected. + */ +enum class HytaleProjectType { + /** Uses net.janrupf.hytale-dev Gradle plugin (detected via tooling model after sync) */ + GRADLE_PLUGIN, + /** Legacy project created via wizard (.hytale/project.json exists) */ + LEGACY_WIZARD, + /** Legacy project with manifest.json only */ + LEGACY_MANUAL, + /** Unknown or not a Hytale project */ + UNKNOWN +} + @Service(Service.Level.PROJECT) class HytaleProjectService(private val project: Project) { @@ -23,11 +42,88 @@ class HytaleProjectService(private val project: Project) { } } + /** + * Detects the type of Hytale project. + * Priority: GRADLE_PLUGIN > LEGACY_WIZARD > LEGACY_MANUAL > UNKNOWN + */ + fun detectProjectType(): HytaleProjectType { + // Check for Gradle plugin detection via DataNode (post-sync) + if (hasHytaleDevDataNode()) { + return HytaleProjectType.GRADLE_PLUGIN + } + + val basePath = project.basePath ?: return HytaleProjectType.UNKNOWN + + // Check for legacy wizard marker + if (File(basePath, ".hytale/project.json").exists()) { + return HytaleProjectType.LEGACY_WIZARD + } + + // Check for various legacy manual setup indicators + val legacyIndicators = listOf( + File(basePath, "src/main/resources/manifest.json"), + File(basePath, "server/HytaleServer.jar"), + File(basePath, "libs/HytaleServer.jar") + ) + + if (legacyIndicators.any { it.exists() }) { + return HytaleProjectType.LEGACY_MANUAL + } + + // Check for HytaleServer dependency in build.gradle + val buildGradle = File(basePath, "build.gradle") + val buildGradleKts = File(basePath, "build.gradle.kts") + val hasHytaleDep = when { + buildGradle.exists() -> buildGradle.readText().contains("HytaleServer") + buildGradleKts.exists() -> buildGradleKts.readText().contains("HytaleServer") + else -> false + } + + if (hasHytaleDep) { + return HytaleProjectType.LEGACY_MANUAL + } + + return HytaleProjectType.UNKNOWN + } + + /** + * Checks if any module has HytaleDevData in its DataNode tree. + * This indicates the net.janrupf.hytale-dev Gradle plugin is applied. + */ + private fun hasHytaleDevDataNode(): Boolean { + val modules = ModuleManager.getInstance(project).modules + for (module in modules) { + val gradleModuleData: DataNode? = GradleUtil.findGradleModuleData(module) + if (gradleModuleData != null) { + val hytaleDevData = gradleModuleData.children.find { it.key == HytaleDevData.KEY } + if (hytaleDevData != null) { + return true + } + } + } + return false + } + + /** + * Returns true if this is any type of Hytale project. + */ fun isHytaleProject(): Boolean { - val basePath = project.basePath ?: return false - val manifestFile = File(basePath, "src/main/resources/manifest.json") - val serverJar = File(basePath, "libs/$SERVER_JAR_NAME") - return manifestFile.exists() || serverJar.exists() + return detectProjectType() != HytaleProjectType.UNKNOWN + } + + /** + * Returns true if this is a Gradle plugin project (uses net.janrupf.hytale-dev). + */ + fun isGradlePluginProject(): Boolean { + return detectProjectType() == HytaleProjectType.GRADLE_PLUGIN + } + + /** + * Returns true if this is a legacy project (wizard or manual). + */ + fun isLegacyProject(): Boolean { + val type = detectProjectType() + return type == HytaleProjectType.LEGACY_WIZARD || type == HytaleProjectType.LEGACY_MANUAL } fun hasServerJar(): Boolean { diff --git a/src/main/kotlin/com/hytaledocs/intellij/toolWindow/CommandAutoCompletePopup.kt b/src/main/kotlin/com/hytaledocs/intellij/toolWindow/CommandAutoCompletePopup.kt new file mode 100644 index 0000000..59780b2 --- /dev/null +++ b/src/main/kotlin/com/hytaledocs/intellij/toolWindow/CommandAutoCompletePopup.kt @@ -0,0 +1,420 @@ +package com.hytaledocs.intellij.toolWindow + +import com.hytaledocs.intellij.services.CommandRegistryCache +import com.hytaledocs.intellij.services.CommandRegistryCache.CommandSuggestion +import com.hytaledocs.intellij.ui.HytaleTheme +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.ui.HintHint +import com.intellij.ui.JBColor +import com.intellij.ui.LightweightHint +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.Alarm +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.* +import java.awt.event.* +import javax.swing.* +import javax.swing.text.JTextComponent + +/** + * Autocomplete popup for server commands in the console tab. + * Shows suggestions as the user types commands starting with '/'. + */ +class CommandAutoCompletePopup( + private val project: Project, + private val textField: JTextComponent, + private val onCommandSelected: ((String) -> Unit)? = null +) : Disposable { + + companion object { + private const val MAX_VISIBLE_ROWS = 8 + private const val POPUP_WIDTH = 350 + private const val DEBOUNCE_DELAY_MS = 100 + } + + private val registryCache = CommandRegistryCache.getInstance(project) + private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this) + + private val listModel = DefaultListModel() + private val suggestionList = JBList(listModel) + private val scrollPane = JBScrollPane(suggestionList) + private var hint: LightweightHint? = null + private var isShowing = false + private var lastSuggestionRequest: Long = 0 + + private val contentPanel = JPanel(BorderLayout()).apply { + add(scrollPane, BorderLayout.CENTER) + background = JBColor.namedColor("PopupMenu.background", UIUtil.getListBackground()) + border = JBUI.Borders.empty(2) + } + + init { + setupList() + setupTextFieldListeners() + } + + private fun setupList() { + suggestionList.selectionMode = ListSelectionModel.SINGLE_SELECTION + suggestionList.cellRenderer = SuggestionCellRenderer() + suggestionList.isFocusable = false // Prevents focus stealing on Wayland + suggestionList.background = JBColor.namedColor("PopupMenu.background", UIUtil.getListBackground()) + suggestionList.border = JBUI.Borders.empty() + + scrollPane.border = JBUI.Borders.empty() + scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + + // Double-click to select + suggestionList.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount == 2) { + applySuggestion() + } + } + }) + } + + private fun setupTextFieldListeners() { + textField.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + when { + isShowing && e.keyCode == KeyEvent.VK_DOWN -> { + moveSelection(1) + e.consume() + } + isShowing && e.keyCode == KeyEvent.VK_UP -> { + moveSelection(-1) + e.consume() + } + isShowing && (e.keyCode == KeyEvent.VK_TAB || e.keyCode == KeyEvent.VK_ENTER) -> { + if (listModel.size > 0) { + applySuggestion() + e.consume() + } + } + isShowing && e.keyCode == KeyEvent.VK_ESCAPE -> { + hidePopup() + e.consume() + } + } + } + }) + + textField.document.addDocumentListener(object : javax.swing.event.DocumentListener { + override fun insertUpdate(e: javax.swing.event.DocumentEvent) = scheduleUpdate() + override fun removeUpdate(e: javax.swing.event.DocumentEvent) = scheduleUpdate() + override fun changedUpdate(e: javax.swing.event.DocumentEvent) = scheduleUpdate() + }) + + // Hide popup when focus is lost + textField.addFocusListener(object : FocusAdapter() { + override fun focusLost(e: FocusEvent) { + // Small delay to handle click-on-popup scenario + SwingUtilities.invokeLater { + if (!textField.isFocusOwner) { + hidePopup() + } + } + } + }) + } + + private fun scheduleUpdate() { + alarm.cancelAllRequests() + alarm.addRequest({ updateSuggestions() }, DEBOUNCE_DELAY_MS) + } + + private fun updateSuggestions() { + val text = textField.text + val caretPos = textField.caretPosition + + // Show suggestions for any non-empty text (/ prefix is optional) + if (text.isEmpty()) { + hidePopup() + return + } + + // Strip / prefix - it's a UI convention, not part of the command + val cleanText = text.removePrefix("/") + + // Get local completions first (immediate) + val localSuggestions = registryCache.getLocalCompletions(cleanText) + + if (localSuggestions.isEmpty() && !registryCache.hasCommandRegistry()) { + // No registry available, hide popup + hidePopup() + return + } + + // Update list with local suggestions + updateListModel(localSuggestions) + + if (localSuggestions.isNotEmpty()) { + showPopup() + } else { + hidePopup() + } + + // Also request dynamic suggestions from bridge (debounced) + // Note: requestSuggestions handles / stripping internally + lastSuggestionRequest = System.currentTimeMillis() + registryCache.requestSuggestions(text, caretPos) { suggestions, startPos -> + SwingUtilities.invokeLater { + // Merge dynamic suggestions with local ones + if (textField.text == text) { // Only if text hasn't changed + mergeDynamicSuggestions(suggestions, startPos) + } + } + } + } + + private fun updateListModel(suggestions: List) { + listModel.clear() + for (suggestion in suggestions.take(MAX_VISIBLE_ROWS * 2)) { + listModel.addElement(SuggestionItem( + text = suggestion.text, + displayText = suggestion.displayText, + description = suggestion.description, + isCommand = suggestion.isCommand, + aliases = suggestion.aliases + )) + } + + if (listModel.size > 0) { + suggestionList.selectedIndex = 0 + } + } + + private fun mergeDynamicSuggestions(dynamicSuggestions: List, startPos: Int) { + if (dynamicSuggestions.isEmpty()) return + + // Add dynamic suggestions that aren't already in the list + val existingTexts = (0 until listModel.size).map { listModel.getElementAt(it).text }.toSet() + + // Get the clean text (without /) for building full suggestions + val cleanText = textField.text.removePrefix("/") + + for (suggestion in dynamicSuggestions) { + val fullText = if (startPos == 0) { + suggestion + } else { + cleanText.substring(0, startPos) + suggestion + } + + if (fullText !in existingTexts) { + listModel.addElement(SuggestionItem( + text = fullText, + displayText = suggestion, + description = "", + isCommand = false, + aliases = emptyList() + )) + } + } + + if (listModel.size > 0 && isShowing) { + adjustPopupSize() + updateHintLocation() + } + } + + private fun showPopup() { + if (isShowing && hint?.isVisible == true) { + adjustPopupSize() + updateHintLocation() + return + } + + // Hide any existing hint + hint?.hide() + hint = null + + adjustPopupSize() + + // Create new LightweightHint with our content + hint = LightweightHint(contentPanel).apply { + setForceLightweightPopup(true) // Force layered pane embedding + } + + // Get the layered pane from the text field's root pane + val rootPane = textField.rootPane ?: return + val layeredPane = rootPane.layeredPane ?: return + + try { + val position = calculatePosition(layeredPane) + val hintHint = HintHint(textField, position) + .setAwtTooltip(false) + .setPreferredPosition(Balloon.Position.above) + .setRequestFocus(false) + + hint?.show(layeredPane, position.x, position.y, textField, hintHint) + isShowing = true + } catch (_: IllegalComponentStateException) { + hidePopup() + } + } + + private fun hidePopup() { + hint?.hide() + hint = null + isShowing = false + } + + private fun adjustPopupSize() { + val rowCount = minOf(listModel.size, MAX_VISIBLE_ROWS) + val rowHeight = suggestionList.fixedCellHeight.takeIf { it > 0 } + ?: suggestionList.getFontMetrics(suggestionList.font).height + JBUI.scale(8) + val height = rowCount * rowHeight + JBUI.scale(8) + + contentPanel.preferredSize = Dimension(JBUI.scale(POPUP_WIDTH), height) + scrollPane.preferredSize = Dimension(JBUI.scale(POPUP_WIDTH), height) + + // If hint is visible, update its size + hint?.pack() + } + + private fun calculatePosition(layeredPane: JLayeredPane): Point { + val fieldLocationInPane = SwingUtilities.convertPoint( + textField, 0, 0, layeredPane + ) + + val popupSize = contentPanel.preferredSize + val paneSize = layeredPane.size + + // Default: position above the text field + var x = fieldLocationInPane.x + var y = fieldLocationInPane.y - popupSize.height - JBUI.scale(2) + + // Boundary fitting - keep within layered pane bounds + // Horizontal: don't go off right edge + if (x + popupSize.width > paneSize.width) { + x = maxOf(0, paneSize.width - popupSize.width) + } + + // Vertical: if doesn't fit above, try below + if (y < 0) { + y = fieldLocationInPane.y + textField.height + JBUI.scale(2) + } + + // If still doesn't fit below, crop to top + if (y + popupSize.height > paneSize.height) { + y = maxOf(0, paneSize.height - popupSize.height) + } + + return Point(x, y) + } + + private fun updateHintLocation() { + val hint = hint ?: return + if (!hint.isVisible) return + + val rootPane = textField.rootPane ?: return + val layeredPane = rootPane.layeredPane ?: return + + try { + val position = calculatePosition(layeredPane) + hint.updateLocation(position.x, position.y) + } catch (_: IllegalComponentStateException) { + hidePopup() + } + } + + private fun moveSelection(delta: Int) { + val currentIndex = suggestionList.selectedIndex + val newIndex = (currentIndex + delta).coerceIn(0, listModel.size - 1) + suggestionList.selectedIndex = newIndex + suggestionList.ensureIndexIsVisible(newIndex) + } + + private fun applySuggestion() { + val selectedItem = suggestionList.selectedValue ?: return + textField.text = selectedItem.text + textField.caretPosition = selectedItem.text.length + hidePopup() + onCommandSelected?.invoke(selectedItem.text) + } + + override fun dispose() { + alarm.cancelAllRequests() + hidePopup() + } + + /** + * Data class for suggestion items in the popup. + */ + data class SuggestionItem( + val text: String, + val displayText: String, + val description: String, + val isCommand: Boolean, + val aliases: List + ) + + /** + * Custom cell renderer for suggestion items. + */ + private class SuggestionCellRenderer : ListCellRenderer { + private val panel = JPanel(BorderLayout()) + private val nameLabel = JLabel() + private val descLabel = JLabel() + + init { + panel.border = JBUI.Borders.empty(4, 8) + panel.isOpaque = true + + nameLabel.font = nameLabel.font.deriveFont(Font.BOLD) + + descLabel.foreground = HytaleTheme.mutedText + descLabel.font = descLabel.font.deriveFont(descLabel.font.size - 1f) + + val textPanel = JPanel(BorderLayout()) + textPanel.isOpaque = false + textPanel.add(nameLabel, BorderLayout.WEST) + textPanel.add(descLabel, BorderLayout.CENTER) + + panel.add(textPanel, BorderLayout.CENTER) + } + + override fun getListCellRendererComponent( + list: JList, + value: SuggestionItem, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + panel.background = if (isSelected) { + JBColor.namedColor("List.selectionBackground", UIUtil.getListSelectionBackground(true)) + } else { + JBColor.namedColor("List.background", UIUtil.getListBackground()) + } + + nameLabel.foreground = if (isSelected) { + JBColor.namedColor("List.selectionForeground", UIUtil.getListSelectionForeground(true)) + } else { + JBColor.namedColor("List.foreground", UIUtil.getListForeground()) + } + + nameLabel.text = value.displayText + + val aliasText = if (value.aliases.isNotEmpty()) { + " (${value.aliases.joinToString(", ")})" + } else "" + + descLabel.text = if (value.description.isNotEmpty()) { + " ${value.description}$aliasText" + } else { + aliasText + } + + descLabel.foreground = if (isSelected) { + JBColor.namedColor("List.selectionInactiveForeground", HytaleTheme.mutedText) + } else { + HytaleTheme.mutedText + } + + return panel + } + } +} diff --git a/src/main/kotlin/com/hytaledocs/intellij/toolWindow/HytaleToolWindowFactory.kt b/src/main/kotlin/com/hytaledocs/intellij/toolWindow/HytaleToolWindowFactory.kt index 7dfb6e1..a87b675 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/toolWindow/HytaleToolWindowFactory.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/toolWindow/HytaleToolWindowFactory.kt @@ -1,6 +1,7 @@ package com.hytaledocs.intellij.toolWindow import com.hytaledocs.intellij.HytaleBundle +import com.hytaledocs.intellij.gradle.HytaleToolWindowRefreshable import com.hytaledocs.intellij.services.* import com.hytaledocs.intellij.settings.AuthMode import com.hytaledocs.intellij.settings.HytaleServerSettings @@ -48,7 +49,7 @@ class HytaleToolWindowFactory : ToolWindowFactory { class HytaleToolWindowPanel( private val project: Project, private val toolWindow: ToolWindow -) : JBPanel(BorderLayout()), Disposable { +) : JBPanel(BorderLayout()), Disposable, HytaleToolWindowRefreshable { private val settings = HytaleServerSettings.getInstance(project) private val javaService = JavaInstallService.getInstance() @@ -86,6 +87,11 @@ class HytaleToolWindowPanel( // Console private var consolePane: JTextPane? = null private val commandField = JBTextField() + private val modeIndicatorLabel = JBLabel("Log Parsing") + private var commandAutoComplete: CommandAutoCompletePopup? = null + + // Console log service + private lateinit var consoleLogService: ConsoleLogService // Command history private val commandHistory = mutableListOf() @@ -106,6 +112,12 @@ class HytaleToolWindowPanel( // Log counter private var logCountLabel: JLabel? = null + // Tabbed pane for refreshing tabs + private lateinit var tabbedPane: JBTabbedPane + + // Track last detected project type for refresh optimization + private var lastDetectedProjectType: HytaleProjectType? = null + // ANSI escape code regex private val ansiRegex = Regex("\u001B\\[[0-9;]*[a-zA-Z]") private val serverLogRegex = Regex("""^\[[\d/]+\s+[\d:]+\s+(\w+)\]\s+(.*)$""") @@ -114,7 +126,13 @@ class HytaleToolWindowPanel( background = JBColor.namedColor("ToolWindow.background", UIUtil.getPanelBackground()) border = JBUI.Borders.empty() - val tabbedPane = JBTabbedPane() + // Initialize console log service + consoleLogService = ConsoleLogService.getInstance(project) + + // Track initial project type + lastDetectedProjectType = HytaleProjectService.getInstance(project).detectProjectType() + + tabbedPane = JBTabbedPane() tabbedPane.tabComponentInsets = JBUI.insets(0) tabbedPane.addTab(HytaleBundle.message("tab.server"), createServerTab()) @@ -133,6 +151,23 @@ class HytaleToolWindowPanel( statsTimer = Timer(1000) { updateStats() } statsTimer?.start() + // Register for console log events + consoleLogService.registerLogCallback { event -> + SwingUtilities.invokeLater { + logUnified(event) + } + } + + // Register for mode changes + consoleLogService.registerModeCallback { mode -> + SwingUtilities.invokeLater { + updateModeIndicator(mode) + } + } + + // Initialize mode indicator + updateModeIndicator(consoleLogService.getMode()) + // Register for authentication events authService.registerCallback(project) { session -> SwingUtilities.invokeLater { @@ -146,20 +181,20 @@ class HytaleToolWindowPanel( private fun logAuthEvent(session: AuthenticationService.AuthSession) { when (session.state) { AuthenticationService.AuthState.AWAITING_CODE -> { - log("[Auth] Waiting for authentication code...", isSystemMessage = true) + consoleLogService.logSystemMessage("[Auth] Waiting for authentication code...") } AuthenticationService.AuthState.CODE_DISPLAYED -> { - log("[Auth] Device code: ${session.deviceCode}", isSystemMessage = true) - log("[Auth] Enter code at: ${session.verificationUrl}", isSystemMessage = true) + consoleLogService.logSystemMessage("[Auth] Device code: ${session.deviceCode}") + consoleLogService.logSystemMessage("[Auth] Enter code at: ${session.verificationUrl}") } AuthenticationService.AuthState.AUTHENTICATING -> { - log("[Auth] Authenticating...", isSystemMessage = true) + consoleLogService.logSystemMessage("[Auth] Authenticating...") } AuthenticationService.AuthState.SUCCESS -> { - log("[Auth] Authentication successful!", isSystemMessage = true) + consoleLogService.logSystemMessage("[Auth] Authentication successful!") } AuthenticationService.AuthState.FAILED -> { - log("[Auth] Authentication failed: ${session.message}", isSystemMessage = true) + consoleLogService.logSystemMessage("[Auth] Authentication failed: ${session.message}") } else -> {} } @@ -211,6 +246,12 @@ class HytaleToolWindowPanel( mainPanel.background = JBColor.namedColor("ToolWindow.background", UIUtil.getPanelBackground()) mainPanel.border = JBUI.Borders.empty(12) + // Check if this is a Gradle plugin project + val projectService = HytaleProjectService.getInstance(project) + if (projectService.isGradlePluginProject()) { + return createGradlePluginServerTab(mainPanel) + } + // Auth panel at top (initially hidden) authPanel = AuthenticationPanel() mainPanel.add(authPanel, BorderLayout.NORTH) @@ -245,6 +286,44 @@ class HytaleToolWindowPanel( return mainPanel } + /** + * Creates a simplified Server tab for Gradle plugin projects. + * Since the Gradle plugin handles server management, we just show an info message. + */ + private fun createGradlePluginServerTab(mainPanel: JPanel): JPanel { + // Initialize authPanel to avoid lateinit issues (won't be displayed) + authPanel = AuthenticationPanel() + + val contentPanel = JPanel() + contentPanel.layout = BoxLayout(contentPanel, BoxLayout.Y_AXIS) + contentPanel.isOpaque = false + + // Info Card + val infoCard = HytaleTheme.createCard("Gradle Plugin Project") + infoCard.maximumSize = Dimension(Int.MAX_VALUE, JBUI.scale(200)) + + val infoText = JBLabel("" + + "

This project uses the net.janrupf.hytale-dev Gradle plugin.

" + + "
" + + "

Server management is handled by Gradle-generated run configurations. " + + "Use the Run dropdown in the toolbar to start the server.

" + + "
" + + "

The Console tab can still be used to view logs when the Dev Bridge connects.

" + + "") + infoText.alignmentX = Component.LEFT_ALIGNMENT + infoCard.add(infoText) + + contentPanel.add(infoCard) + contentPanel.add(Box.createVerticalGlue()) + + val scrollPane = JBScrollPane(contentPanel) + scrollPane.border = null + scrollPane.viewportBorder = null + mainPanel.add(scrollPane, BorderLayout.CENTER) + + return mainPanel + } + private fun createStatusCard(): JPanel { val card = HytaleTheme.createCard(HytaleBundle.message("card.environment.title")) card.maximumSize = Dimension(Int.MAX_VALUE, card.preferredSize.height) @@ -428,13 +507,15 @@ class HytaleToolWindowPanel( val topPanel = JPanel(BorderLayout()) topPanel.isOpaque = false - // Toolbar - val toolbar = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), JBUI.scale(4))) + // Toolbar with BoxLayout for proper glue support + val toolbar = JPanel() + toolbar.layout = BoxLayout(toolbar, BoxLayout.X_AXIS) toolbar.isOpaque = false toolbar.border = BorderFactory.createCompoundBorder( BorderFactory.createMatteBorder(0, 0, 1, 0, HytaleTheme.cardBorder), JBUI.Borders.empty(4, 8) ) + toolbar.add(Box.createHorizontalStrut(JBUI.scale(4))) toolbar.add(HytaleTheme.createButton(HytaleBundle.message("button.clear"), AllIcons.Actions.GC).apply { addActionListener { consolePane?.text = "" @@ -442,6 +523,7 @@ class HytaleToolWindowPanel( updateLogCount() } }) + toolbar.add(Box.createHorizontalStrut(JBUI.scale(4))) toolbar.add(HytaleTheme.createButton(HytaleBundle.message("button.copyAll"), AllIcons.Actions.Copy).apply { addActionListener { consolePane?.let { @@ -451,6 +533,16 @@ class HytaleToolWindowPanel( } } }) + + // Spacer to push mode indicator to the right + toolbar.add(Box.createHorizontalGlue()) + + // Mode indicator (Log Parsing / Bridge Connected) + modeIndicatorLabel.font = modeIndicatorLabel.font.deriveFont(Font.ITALIC, JBUI.scaleFontSize(11f).toFloat()) + modeIndicatorLabel.border = JBUI.Borders.empty(0, 8) + toolbar.add(modeIndicatorLabel) + toolbar.add(Box.createHorizontalStrut(JBUI.scale(4))) + topPanel.add(toolbar, BorderLayout.NORTH) // Search bar @@ -493,8 +585,14 @@ class HytaleToolWindowPanel( commandField.border = JBUI.Borders.empty(4, 8) commandField.emptyText.text = HytaleBundle.message("console.commandPlaceholder") + // Disable Tab for focus traversal so autocomplete can use it + commandField.setFocusTraversalKeysEnabled(false) commandPanel.add(commandField, BorderLayout.CENTER) + // Setup command autocomplete popup + commandAutoComplete = CommandAutoCompletePopup(project, commandField) + Disposer.register(this, commandAutoComplete!!) + val sendButton = HytaleTheme.createButton(HytaleBundle.message("button.send"), AllIcons.Actions.Execute) sendButton.addActionListener { sendCommand() } commandPanel.add(sendButton, BorderLayout.EAST) @@ -924,9 +1022,9 @@ class HytaleToolWindowPanel( ) consolePane?.text = "" - log("Starting Hytale server...", isSystemMessage = true) - log("Auth mode: ${settings.authMode.displayName}", isSystemMessage = true) - log("Memory: ${settings.minMemory} - ${settings.maxMemory}", isSystemMessage = true) + consoleLogService.logSystemMessage("Starting Hytale server...") + consoleLogService.logSystemMessage("Auth mode: ${settings.authMode.displayName}") + consoleLogService.logSystemMessage("Memory: ${settings.minMemory} - ${settings.maxMemory}") // Record profiler event profiler.recordEvent(ServerProfiler.EventType.SERVER_START) @@ -935,9 +1033,8 @@ class HytaleToolWindowPanel( launchService.startServer(config, logCallback = { line -> - SwingUtilities.invokeLater { - log(line, isSystemMessage = false) - } + // Route through ConsoleLogService for unified handling + consoleLogService.onFallbackLog(line) }, statusCallback = { status -> SwingUtilities.invokeLater { @@ -975,6 +1072,7 @@ class HytaleToolWindowPanel( private fun stopServer() { val launchService = ServerLaunchService.getInstance(project) log("Stopping server...", isSystemMessage = true) + consoleLogService.logSystemMessage("Stopping server...") notify(HytaleBundle.message("notification.serverStopped"), NotificationType.INFORMATION) // Disable buttons while stopping @@ -983,7 +1081,10 @@ class HytaleToolWindowPanel( launchService.stopServer( logCallback = { line -> - SwingUtilities.invokeLater { log(line, isSystemMessage = true) } + SwingUtilities.invokeLater { + log(line, isSystemMessage = true) + consoleLogService.logSystemMessage(line) + } }, statusCallback = { status -> SwingUtilities.invokeLater { @@ -1004,13 +1105,27 @@ class HytaleToolWindowPanel( } private fun sendCommand() { - val command = commandField.text.trim() - if (command.isNotEmpty()) { - val launchService = ServerLaunchService.getInstance(project) - if (launchService.sendCommand(command)) { + val rawCommand = commandField.text.trim() + if (rawCommand.isNotEmpty()) { + // Strip leading slash - it's a UI convention, commands don't include it + val command = rawCommand.removePrefix("/") + + // Try to use bridge connection first if available + val bridgeConnection = consoleLogService.getActiveConnection() + val success = if (bridgeConnection != null) { + bridgeConnection.executeCommand(command) + // Don't log here - bridge logs the command + true + } else { + // Fall back to stdin + val launchService = ServerLaunchService.getInstance(project) + launchService.sendCommand(command) + } + + if (success) { // Add to history (remove duplicate if exists, add to front) - commandHistory.remove(command) - commandHistory.add(0, command) + commandHistory.remove(rawCommand) + commandHistory.add(0, rawCommand) if (commandHistory.size > maxHistorySize) { commandHistory.removeAt(commandHistory.lastIndex) } @@ -1019,7 +1134,8 @@ class HytaleToolWindowPanel( // Record profiler event profiler.recordEvent(ServerProfiler.EventType.COMMAND_SENT, command) - log("> $command", isSystemMessage = true) + log("> $rawCommand", isSystemMessage = true) + consoleLogService.logSystemMessage("> $rawCommand") commandField.text = "" } } @@ -1311,9 +1427,103 @@ class HytaleToolWindowPanel( doc.insertString(doc.length, text, colorStyle) } + /** + * Log a unified log event from ConsoleLogService. + * Handles both system messages and structured log events from fallback or bridge. + */ + private fun logUnified(event: ConsoleLogService.UnifiedLogEvent) { + consolePane?.let { pane -> + val doc = pane.styledDocument + + if (event.isSystemMessage) { + val finalMessage = if (settings.showTimestamps) { + "[${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))}] ${event.message}" + } else { + event.message + } + appendStyled(doc, "$finalMessage\n", HytaleTheme.textPrimary) + } else { + // Get color based on log level + val levelColor = when (event.level) { + ConsoleLogService.UnifiedLogLevel.TRACE -> HytaleTheme.mutedText + ConsoleLogService.UnifiedLogLevel.DEBUG -> HytaleTheme.mutedText + ConsoleLogService.UnifiedLogLevel.INFO -> HytaleTheme.successColor + ConsoleLogService.UnifiedLogLevel.WARNING -> HytaleTheme.warningColor + ConsoleLogService.UnifiedLogLevel.ERROR -> HytaleTheme.errorColor + ConsoleLogService.UnifiedLogLevel.FATAL -> HytaleTheme.errorColor + else -> HytaleTheme.textPrimary + } + + val levelName = event.level.name + + // If we have structured data from bridge, show logger name + if (event.loggerName != null && consoleLogService.isBridgeConnected()) { + // Extract short logger name (last part after dot) + val shortLogger = event.loggerName.substringAfterLast('.') + appendStyled(doc, "[$levelName] ", levelColor) + appendStyled(doc, "[$shortLogger] ", HytaleTheme.mutedText) + appendStyled(doc, "${event.message}\n", HytaleTheme.textPrimary) + } else { + appendStyled(doc, "[$levelName] ", levelColor) + appendStyled(doc, "${event.message}\n", HytaleTheme.textPrimary) + } + + // Show throwable if present + if (!event.throwable.isNullOrEmpty()) { + appendStyled(doc, "${event.throwable}\n", HytaleTheme.errorColor) + } + } + + if (settings.autoScroll) { + pane.caretPosition = doc.length + } + } + } + + /** + * Update the mode indicator label based on current console log mode. + */ + private fun updateModeIndicator(mode: ConsoleLogService.ConsoleLogMode) { + when (mode) { + ConsoleLogService.ConsoleLogMode.FALLBACK_PARSING -> { + modeIndicatorLabel.text = "Log Parsing" + modeIndicatorLabel.foreground = HytaleTheme.warningColor + modeIndicatorLabel.toolTipText = "Parsing logs from stdout (bridge not connected)" + } + ConsoleLogService.ConsoleLogMode.BRIDGE_CONNECTED -> { + modeIndicatorLabel.text = "Bridge Connected" + modeIndicatorLabel.foreground = HytaleTheme.successColor + modeIndicatorLabel.toolTipText = "Receiving structured logs from dev bridge" + } + } + } + private fun notify(message: String, type: NotificationType) = PanelUtils.notify(project, "Hytale", message, type) + /** + * Called when project type detection may have changed (e.g., after Gradle sync). + * Refreshes the Server tab if the project type has changed. + */ + override fun onProjectTypeChanged() { + val currentProjectType = HytaleProjectService.getInstance(project).detectProjectType() + + // Only refresh if the project type actually changed + if (currentProjectType == lastDetectedProjectType) { + return + } + + lastDetectedProjectType = currentProjectType + + // Refresh the Server tab (index 0) + SwingUtilities.invokeLater { + val newServerTab = createServerTab() + tabbedPane.setComponentAt(0, newServerTab) + tabbedPane.revalidate() + tabbedPane.repaint() + } + } + override fun dispose() { statsTimer?.stop() statsTimer = null diff --git a/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleModuleBuilder.kt b/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleModuleBuilder.kt index a6d26fe..b983913 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleModuleBuilder.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleModuleBuilder.kt @@ -209,6 +209,7 @@ class HytaleModuleBuilder : ModuleBuilder() { var buildSystem: String = "Gradle" // "Gradle" or "Maven" var hytaleInstallation: HytaleInstallation? = detectHytaleInstallation() var copyFromGame: Boolean = hytaleInstallation != null + var useGradlePlugin: Boolean = true // Default to Gradle plugin mode override fun getModuleType(): ModuleType<*> = JavaModuleType.getModuleType() @@ -244,11 +245,15 @@ class HytaleModuleBuilder : ModuleBuilder() { // Create base directories val baseDirs = mutableListOf( "src/main/$srcLang/$packagePath", - "src/main/resources", - "libs", - "server" + "src/main/resources" ) + // Only create libs/server directories for legacy mode + if (!useGradlePlugin) { + baseDirs.add("libs") + baseDirs.add("server") + } + // Add template-specific directories when (templateType) { TemplateType.EMPTY -> { @@ -270,20 +275,30 @@ class HytaleModuleBuilder : ModuleBuilder() { when (buildSystem) { "Maven" -> { generatePomXml(basePath) + copyServerFiles(basePath) + generateManifestForMode(basePath, packagePath, modFolder) } else -> { // Gradle - generateBuildGradle(basePath) - generateSettingsGradle(basePath) + if (useGradlePlugin) { + generateBuildGradleKts(basePath) + generateSettingsGradleKts(basePath) + // No manifest - plugin generates it + // No server files - plugin finds installation + } else { + generateBuildGradle(basePath) + generateSettingsGradle(basePath) + copyServerFiles(basePath) + generateManifestForMode(basePath, packagePath, modFolder) + } generateGradleWrapper(basePath) } } generateGitignore(basePath) - // Generate template-specific files + // Generate template-specific source files when (templateType) { TemplateType.EMPTY -> { - generateManifestEmpty(basePath) if (language == "Kotlin") { generateMainClassEmptyKotlin(basePath, packagePath) } else { @@ -291,7 +306,6 @@ class HytaleModuleBuilder : ModuleBuilder() { } } TemplateType.FULL -> { - generateManifest(basePath) if (language == "Kotlin") { generateMainClassKotlin(basePath, packagePath) generateMainCommandKotlin(basePath, packagePath) @@ -310,11 +324,17 @@ class HytaleModuleBuilder : ModuleBuilder() { } } - // Copy server files from game installation or configured path - copyServerFiles(basePath) + // Only generate .hytale/project.json and run configurations for legacy mode + if (!useGradlePlugin) { + generateRunConfigurations(basePath) + } + } + } - // Generate IntelliJ run configurations - generateRunConfigurations(basePath) + private fun generateManifestForMode(basePath: String, packagePath: String, modFolder: String) { + when (templateType) { + TemplateType.EMPTY -> generateManifestEmpty(basePath) + TemplateType.FULL -> generateManifest(basePath) } } @@ -399,6 +419,21 @@ class HytaleModuleBuilder : ModuleBuilder() { } private fun generateGitignore(basePath: String) { + val hytaleIgnores = if (useGradlePlugin) { + """ + # Hytale Dev Plugin + run/ + """.trimIndent() + } else { + """ + # Hytale (Legacy) + libs/HytaleServer.jar + server/mods/ + server/logs/ + server/world/ + """.trimIndent() + } + File(basePath, ".gitignore").writeText(""" # Gradle .gradle/ @@ -415,11 +450,7 @@ class HytaleModuleBuilder : ModuleBuilder() { !.idea/runConfigurations/ *.iml - # Hytale - libs/HytaleServer.jar - server/mods/ - server/logs/ - server/world/ + $hytaleIgnores """.trimIndent()) } @@ -789,6 +820,67 @@ class HytaleModuleBuilder : ModuleBuilder() { File(basePath, "settings.gradle").writeText("rootProject.name = '$modId'") } + private fun generateBuildGradleKts(basePath: String) { + val isKotlin = language == "Kotlin" + val kotlinPlugin = if (isKotlin) "\n kotlin(\"jvm\") version \"2.1.0\"" else "" + val kotlinDeps = if (isKotlin) "\n implementation(kotlin(\"stdlib\"))" else "" + + val className = modName.replace(" ", "") + "Plugin" + val escapedDescription = modDescription.replace("\"", "\\\"") + val groupId = packageName.substringBeforeLast('.') + val includesAssetPack = templateType == TemplateType.FULL + + File(basePath, "build.gradle.kts").writeText(""" +plugins { + id("net.janrupf.hytale-dev") version "0.2.0" + java$kotlinPlugin +} + +group = "$groupId" +version = "$version" + +repositories { + mavenCentral() +} + +hytale { + manifest { + main("$packageName.$className") + description("$escapedDescription") + author { + name("$author") + } + includesAssetPack($includesAssetPack) + } +} + +dependencies { + compileOnly(hytaleServer()) + 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) + } +} + """.trimIndent()) + } + + private fun generateSettingsGradleKts(basePath: String) { + File(basePath, "settings.gradle.kts").writeText(""" +rootProject.name = "$modId" + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + """.trimIndent()) + } + private fun generateManifestEmpty(basePath: String) { val className = modName.replace(" ", "") + "Plugin" // Plugin name must have no spaces for hot reload to work diff --git a/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleNewProjectWizard.kt b/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleNewProjectWizard.kt index 4541dec..8415bdf 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleNewProjectWizard.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleNewProjectWizard.kt @@ -55,6 +55,10 @@ class HytaleProjectWizardStep(parent: NewProjectWizardStep) : AbstractNewProject private lateinit var languageSegmentedButton: SegmentedButton private lateinit var buildSystemSegmentedButton: SegmentedButton + // Gradle plugin mode + private var useGradlePlugin = true + private val useGradlePluginCheckbox = JCheckBox("Use Gradle Dev Plugin (recommended)", true) + // Game detection - mutable to allow manual selection private var hytaleInstallation = HytaleModuleBuilder.detectHytaleInstallation() private val copyFromGameCheckbox = JCheckBox("Copy server files automatically", hytaleInstallation != null) @@ -63,6 +67,7 @@ class HytaleProjectWizardStep(parent: NewProjectWizardStep) : AbstractNewProject private var serverPanel: JPanel? = null private var serverStatusLabel: JBLabel? = null private var serverStatusIcon: JBLabel? = null + private var serverPanelContainer: JPanel? = null // Track manual edits private var modIdManuallyEdited = false @@ -464,9 +469,21 @@ class HytaleProjectWizardStep(parent: NewProjectWizardStep) : AbstractNewProject } row("Build System:") { buildSystemSegmentedButton = segmentedButton(listOf("Gradle", "Maven")) { text = it } - .whenItemSelected { selectedBuildSystem = it } + .whenItemSelected { + selectedBuildSystem = it + // Disable Gradle plugin checkbox when Maven is selected + useGradlePluginCheckbox.isEnabled = (it == "Gradle") + if (it == "Maven") { + useGradlePluginCheckbox.isSelected = false + useGradlePlugin = false + } + } buildSystemSegmentedButton.selectedItem = selectedBuildSystem } + row("") { + cell(useGradlePluginCheckbox) + .comment("Automatic server detection, manifest generation, and run configurations") + } row("Version:") { cell(versionField) .columns(15) @@ -478,6 +495,11 @@ class HytaleProjectWizardStep(parent: NewProjectWizardStep) : AbstractNewProject .comment("In-game command shortcut") } } + + // Track checkbox changes + useGradlePluginCheckbox.addActionListener { + useGradlePlugin = useGradlePluginCheckbox.isSelected + } formPanel.alignmentX = Component.LEFT_ALIGNMENT content.add(formPanel) content.add(Box.createVerticalGlue()) @@ -502,7 +524,7 @@ class HytaleProjectWizardStep(parent: NewProjectWizardStep) : AbstractNewProject titleLabel.border = JBUI.Borders.emptyBottom(4) content.add(titleLabel) - val subtitleLabel = JBLabel("Add metadata and configure server files") + val subtitleLabel = JBLabel("Add metadata for your mod") subtitleLabel.foreground = JBColor.GRAY subtitleLabel.alignmentX = Component.LEFT_ALIGNMENT subtitleLabel.border = JBUI.Borders.emptyBottom(20) @@ -536,8 +558,15 @@ class HytaleProjectWizardStep(parent: NewProjectWizardStep) : AbstractNewProject content.add(formPanel) content.add(Box.createVerticalStrut(20)) - // Server detection - content.add(createServerPanel()) + // Server detection container (only shown in legacy mode) + val container = JPanel() + container.layout = BoxLayout(container, BoxLayout.Y_AXIS) + container.isOpaque = false + container.alignmentX = Component.LEFT_ALIGNMENT + serverPanelContainer = container + + container.add(createServerPanel()) + content.add(container) content.add(Box.createVerticalGlue()) @@ -721,9 +750,18 @@ class HytaleProjectWizardStep(parent: NewProjectWizardStep) : AbstractNewProject cardLayout.show(cardPanel, "step${currentStep + 1}") updateStepIndicators() updateNavigationButtons() + + // Update server panel visibility when entering Step 3 + if (currentStep == 2) { + updateServerPanelVisibility() + } } } + private fun updateServerPanelVisibility() { + serverPanelContainer?.isVisible = !useGradlePlugin + } + private fun goToPreviousStep() { if (currentStep > 0) { currentStep-- @@ -764,6 +802,7 @@ class HytaleProjectWizardStep(parent: NewProjectWizardStep) : AbstractNewProject builder.buildSystem = selectedBuildSystem builder.copyFromGame = copyFromGameCheckbox.isSelected builder.hytaleInstallation = hytaleInstallation + builder.useGradlePlugin = useGradlePluginCheckbox.isSelected && selectedBuildSystem == "Gradle" val projectPath = context.projectDirectory?.toString() ?: return builder.createProjectAtPath(projectPath) diff --git a/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleWizardStep.kt b/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleWizardStep.kt index 110dbfc..1201efe 100644 --- a/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleWizardStep.kt +++ b/src/main/kotlin/com/hytaledocs/intellij/wizard/HytaleWizardStep.kt @@ -16,6 +16,7 @@ import com.intellij.util.ui.JBUI import java.awt.* import java.io.File import javax.swing.* +import javax.swing.Box import javax.swing.event.DocumentEvent import javax.swing.event.DocumentListener @@ -47,6 +48,9 @@ class HytaleWizardStep( private val copyFromGameCheckbox = JCheckBox("Copy server files from game installation", builder.copyFromGame) private val gameStatusLabel = JBLabel() + // Gradle plugin mode + private val useGradlePluginCheckbox = JCheckBox("Use Gradle Dev Plugin (recommended)", builder.useGradlePlugin) + // Track manual edits private var modIdManuallyEdited = false private var packageNameManuallyEdited = false @@ -275,6 +279,11 @@ class HytaleWizardStep( addFormRow(panel, gbc, row++, "Type:", createTemplatePanel(), "Choose between an empty mod or a full-featured template") + // === Build Configuration Section === + addSectionHeader(panel, gbc, row++, "Build Configuration") + + row = addBuildConfigurationSection(panel, gbc, row) + // === Mod Information Section === addSectionHeader(panel, gbc, row++, "Mod Information") @@ -447,6 +456,40 @@ class HytaleWizardStep( panel.add(hintLabel, gbc) } + private fun addBuildConfigurationSection(panel: JPanel, gbc: GridBagConstraints, row: Int): Int { + gbc.gridx = 0 + gbc.gridy = row + gbc.gridwidth = 2 + gbc.fill = GridBagConstraints.HORIZONTAL + gbc.insets = JBUI.insets(4, 0, 4, 0) + gbc.weightx = 1.0 + + val configPanel = JPanel() + configPanel.layout = BoxLayout(configPanel, BoxLayout.Y_AXIS) + configPanel.border = JBUI.Borders.empty(6) + configPanel.background = JBColor(Color(240, 248, 255), Color(40, 45, 50)) + + useGradlePluginCheckbox.toolTipText = + "Uses net.janrupf.hytale-dev for automatic server detection and run configurations" + useGradlePluginCheckbox.isOpaque = false + useGradlePluginCheckbox.alignmentX = Component.LEFT_ALIGNMENT + configPanel.add(useGradlePluginCheckbox) + + configPanel.add(Box.createVerticalStrut(4)) + + val desc = JBLabel("The Gradle plugin handles server JAR detection, " + + "manifest generation, and IntelliJ run configurations automatically.") + desc.font = desc.font.deriveFont(11f) + desc.foreground = JBColor.GRAY + desc.alignmentX = Component.LEFT_ALIGNMENT + configPanel.add(desc) + + panel.add(configPanel, gbc) + + gbc.gridwidth = 1 + return row + 1 + } + override fun updateDataModel() { // Update builder builder.modName = modNameField.text.trim() @@ -463,6 +506,7 @@ class HytaleWizardStep( HytaleModuleBuilder.TemplateType.FULL } builder.copyFromGame = copyFromGameCheckbox.isSelected + builder.useGradlePlugin = useGradlePluginCheckbox.isSelected // Update wizard context with project location val projectPath = projectLocationField.text.trim() + "/" + projectNameField.text.trim() diff --git a/src/main/proto/hytale_bridge.proto b/src/main/proto/hytale_bridge.proto new file mode 100644 index 0000000..47ae27a --- /dev/null +++ b/src/main/proto/hytale_bridge.proto @@ -0,0 +1,164 @@ +syntax = "proto3"; + +package com.hytaledocs.intellij.bridge.protocol; + +option java_package = "com.hytaledocs.intellij.bridge.protocol"; +option java_outer_classname = "HytaleBridgeProto"; + +// ============================================================================= +// Root message types for bidirectional communication +// ============================================================================= + +// Messages from Agent/Bridge to IDE +message AgentMessage { + oneof payload { + AgentHello hello = 1; + LogEvent log_event = 2; + CommandRegistryResponse command_registry = 3; + SuggestionsResponse suggestions = 4; + AssetPathsEvent asset_paths = 5; + ServerStateEvent server_state = 6; + TranslateResponse translate_response = 7; + } +} + +// Messages from IDE to Agent/Bridge +message IdeMessage { + oneof payload { + IdeHello hello = 1; + GetCommandsRequest get_commands = 2; + GetSuggestionsRequest get_suggestions = 3; + ExecuteCommandRequest execute_command = 4; + TranslateRequest translate = 5; + } +} + +// ============================================================================= +// Handshake Messages +// ============================================================================= + +message AgentHello { + int32 protocol_version = 1; + string agent_version = 2; + repeated string capabilities = 3; // e.g., "logs", "commands", "assets" + string server_version = 4; +} + +message IdeHello { + int32 protocol_version = 1; + string plugin_version = 2; + repeated string requested_capabilities = 3; +} + +// ============================================================================= +// Log Messages +// ============================================================================= + +message LogEvent { + int64 timestamp = 1; + LogLevel level = 2; + string logger_name = 3; + string message = 4; + string throwable = 5; // Stack trace if present + string thread_name = 6; +} + +enum LogLevel { + LOG_LEVEL_UNKNOWN = 0; + LOG_LEVEL_TRACE = 1; + LOG_LEVEL_DEBUG = 2; + LOG_LEVEL_INFO = 3; + LOG_LEVEL_WARNING = 4; + LOG_LEVEL_ERROR = 5; + LOG_LEVEL_FATAL = 6; +} + +// ============================================================================= +// Command Messages +// ============================================================================= + +message GetCommandsRequest { + // Empty - request full command registry +} + +message CommandRegistryResponse { + repeated CommandInfo commands = 1; +} + +message CommandInfo { + string name = 1; + string description = 2; // Translation key + repeated string aliases = 3; + string permission = 4; + repeated ArgumentInfo required_args = 5; + repeated ArgumentInfo optional_args = 6; + repeated CommandInfo sub_commands = 7; +} + +message ArgumentInfo { + string name = 1; + string description = 2; + string type_name = 3; + repeated string examples = 4; + bool is_list = 5; + bool is_required = 6; +} + +// ============================================================================= +// Suggestions Messages +// ============================================================================= + +message GetSuggestionsRequest { + string partial_command = 1; + int32 cursor_position = 2; +} + +message SuggestionsResponse { + repeated string suggestions = 1; + int32 start_position = 2; // Where suggestion replaces from +} + +// ============================================================================= +// Translation Messages +// ============================================================================= + +message TranslateRequest { + repeated string keys = 1; + optional string language = 2; // If not set, uses server default ("en") +} + +message TranslateResponse { + map translations = 1; // key -> translated text +} + +// ============================================================================= +// Command Execution +// ============================================================================= + +message ExecuteCommandRequest { + string command = 1; +} + +// ============================================================================= +// Asset Messages +// ============================================================================= + +message AssetPathsEvent { + repeated string paths = 1; // Absolute paths to plugin asset directories +} + +// ============================================================================= +// Server State +// ============================================================================= + +message ServerStateEvent { + ServerState state = 1; +} + +enum ServerState { + SERVER_STATE_UNKNOWN = 0; + SERVER_STATE_STARTING = 1; + SERVER_STATE_READY = 2; + SERVER_STATE_STOPPING = 3; + SERVER_STATE_STOPPED = 4; +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 8c66ebf..ddcdfd6 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -182,6 +182,7 @@ com.intellij.modules.platform com.intellij.modules.java + org.jetbrains.plugins.gradle messages.HytaleBundle @@ -297,6 +298,12 @@ serviceImplementation="com.hytaledocs.intellij.services.AssetSyncService"/> + + + @@ -344,6 +351,22 @@ + + + + + + + + + + + + + +