From f095c2fe0de82dd24fffdcb18e5eddf31e25de0d Mon Sep 17 00:00:00 2001 From: Bradley Dwyer Date: Thu, 22 Aug 2024 11:00:38 +1000 Subject: [PATCH] Initial basic support for the FTL LSP Server in IntelliJ Ultimate as a plugin. - Basic completion of go directives - start/stop/restart of the LSP - lsp terminal output (auto scroll/clear buffer/soft wrap) - settings panel for LSP server command configuration and project working directory --- extensions/intellij/build.gradle.kts | 47 ++++ extensions/intellij/gradle.properties | 8 + extensions/intellij/gradlew | 234 ++++++++++++++++++ extensions/intellij/gradlew.bat | 89 +++++++ extensions/intellij/settings.gradle.kts | 8 + .../block/ftl/intellij/CustomLsp4jClient.kt | 10 + .../main/kotlin/xyz/block/ftl/intellij/FTL.kt | 9 + .../ftl/intellij/FTLLspServerDescriptor.kt | 49 ++++ .../block/ftl/intellij/FTLLspServerService.kt | 15 ++ .../intellij/FTLLspServerSupportProvider.kt | 123 +++++++++ .../xyz/block/ftl/intellij/FTLSettings.kt | 39 +++ .../ftl/intellij/FTLSettingsComponent.kt | 57 +++++ .../ftl/intellij/FTLSettingsConfigurable.kt | 49 ++++ .../intellij/toolWindow/FTLMessagesPanel.kt | 33 +++ .../toolWindow/FTLToolWindowFactory.kt | 220 ++++++++++++++++ .../src/main/resources/META-INF/plugin.xml | 48 ++++ .../main/resources/META-INF/pluginIcon.svg | 10 + 17 files changed, 1048 insertions(+) create mode 100644 extensions/intellij/build.gradle.kts create mode 100644 extensions/intellij/gradle.properties create mode 100755 extensions/intellij/gradlew create mode 100644 extensions/intellij/gradlew.bat create mode 100644 extensions/intellij/settings.gradle.kts create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/CustomLsp4jClient.kt create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTL.kt create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerDescriptor.kt create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerService.kt create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerSupportProvider.kt create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettings.kt create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettingsComponent.kt create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettingsConfigurable.kt create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/toolWindow/FTLMessagesPanel.kt create mode 100644 extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/toolWindow/FTLToolWindowFactory.kt create mode 100644 extensions/intellij/src/main/resources/META-INF/plugin.xml create mode 100644 extensions/intellij/src/main/resources/META-INF/pluginIcon.svg diff --git a/extensions/intellij/build.gradle.kts b/extensions/intellij/build.gradle.kts new file mode 100644 index 0000000000..e290aa5777 --- /dev/null +++ b/extensions/intellij/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("java") + id("org.jetbrains.kotlin.jvm") version "1.9.24" + id("org.jetbrains.intellij") version "1.17.3" +} + +group = "xyz.block.ftl" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +// Configure Gradle IntelliJ Plugin +// Read more: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html +intellij { + version.set("2024.1.3") + type.set("IU") // Target IDE Platform + + plugins.set(listOf(/* Plugin Dependencies */)) +} + +tasks { + // Set the JVM compatibility versions + withType { + sourceCompatibility = "17" + targetCompatibility = "17" + } + withType { + kotlinOptions.jvmTarget = "17" + } + + patchPluginXml { + sinceBuild.set("241") + untilBuild.set("241.*") + } + + signPlugin { + certificateChain.set(System.getenv("CERTIFICATE_CHAIN")) + privateKey.set(System.getenv("PRIVATE_KEY")) + password.set(System.getenv("PRIVATE_KEY_PASSWORD")) + } + + publishPlugin { + token.set(System.getenv("PUBLISH_TOKEN")) + } +} diff --git a/extensions/intellij/gradle.properties b/extensions/intellij/gradle.properties new file mode 100644 index 0000000000..6c35a1de39 --- /dev/null +++ b/extensions/intellij/gradle.properties @@ -0,0 +1,8 @@ +# Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib +kotlin.stdlib.default.dependency = false + +# Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html +org.gradle.configuration-cache = true + +# Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html +org.gradle.caching = true diff --git a/extensions/intellij/gradlew b/extensions/intellij/gradlew new file mode 100755 index 0000000000..1b6c787337 --- /dev/null +++ b/extensions/intellij/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/extensions/intellij/gradlew.bat b/extensions/intellij/gradlew.bat new file mode 100644 index 0000000000..107acd32c4 --- /dev/null +++ b/extensions/intellij/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/extensions/intellij/settings.gradle.kts b/extensions/intellij/settings.gradle.kts new file mode 100644 index 0000000000..b34041241a --- /dev/null +++ b/extensions/intellij/settings.gradle.kts @@ -0,0 +1,8 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "intellij" \ No newline at end of file diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/CustomLsp4jClient.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/CustomLsp4jClient.kt new file mode 100644 index 0000000000..7dcd70b3c4 --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/CustomLsp4jClient.kt @@ -0,0 +1,10 @@ +package xyz.block.ftl.intellij + +import com.intellij.platform.lsp.api.Lsp4jClient +import com.intellij.platform.lsp.api.LspServerNotificationsHandler + +class CustomLsp4jClient(handler: LspServerNotificationsHandler) : Lsp4jClient(handler) { + override fun telemetryEvent(`object`: Any) { + super.telemetryEvent(`object`) + } +} diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTL.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTL.kt new file mode 100644 index 0000000000..33171d8c06 --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTL.kt @@ -0,0 +1,9 @@ +package xyz.block.ftl.intellij + +import com.intellij.openapi.application.ApplicationManager + +fun runOnEDT(runnable: () -> Unit) { + ApplicationManager.getApplication().invokeLater { + runnable() + } +} diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerDescriptor.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerDescriptor.kt new file mode 100644 index 0000000000..3d31f8951a --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerDescriptor.kt @@ -0,0 +1,49 @@ +package xyz.block.ftl.intellij + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.OSProcessHandler +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspServerNotificationsHandler +import com.intellij.platform.lsp.api.ProjectWideLspServerDescriptor +import xyz.block.ftl.intellij.toolWindow.FTLMessagesToolWindowFactory +import java.util.regex.Pattern + +class FTLLspServerDescriptor(project: Project) : ProjectWideLspServerDescriptor(project, "FTL") { + override fun isSupportedFile(file: VirtualFile) = file.extension == "go" + + override fun createLsp4jClient(handler: LspServerNotificationsHandler): CustomLsp4jClient { + return CustomLsp4jClient(handler) + } + + override fun createCommandLine(): GeneralCommandLine { + val settings = AppSettings.getInstance().state + val generalCommandLine = + GeneralCommandLine(listOf(settings.lspServerPath) + settings.lspServerArguments.split(Pattern.compile("\\s+"))) + generalCommandLine.setWorkDirectory(project.basePath) + displayMessageInToolWindow("LSP Server Command: " + generalCommandLine.commandLineString) + displayMessageInToolWindow("Working Directory: " + generalCommandLine.workDirectory) + return generalCommandLine + } + + override fun startServerProcess(): OSProcessHandler { + displayMessageInToolWindow("Starting FTL LSP Server") + val processHandler = super.startServerProcess() + processHandler.addProcessListener(object : ProcessAdapter() { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + val message = event.text.trim() + if (message.isNotBlank()) { + displayMessageInToolWindow(message) + } + } + }) + return processHandler + } + + private fun displayMessageInToolWindow(message: String) { + FTLMessagesToolWindowFactory.Util.displayMessageInToolWindow(project, message) + } +} diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerService.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerService.kt new file mode 100644 index 0000000000..f381f2b7c6 --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerService.kt @@ -0,0 +1,15 @@ +package xyz.block.ftl.intellij + +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project + +@Service(Service.Level.PROJECT) +class FTLLspServerService(val project: Project) { + val lspServerSupportProvider = FTLLspServerSupportProvider() + + companion object { + fun getInstance(project: Project): FTLLspServerService { + return project.getService(FTLLspServerService::class.java) + } + } +} diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerSupportProvider.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerSupportProvider.kt new file mode 100644 index 0000000000..51e49aec90 --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLLspServerSupportProvider.kt @@ -0,0 +1,123 @@ +package xyz.block.ftl.intellij + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.OSProcessHandler +import com.intellij.icons.AllIcons.Icons +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspServer +import com.intellij.platform.lsp.api.LspServerDescriptor.Companion.LOG +import com.intellij.platform.lsp.api.LspServerManager +import com.intellij.platform.lsp.api.LspServerManagerListener +import com.intellij.platform.lsp.api.LspServerState +import com.intellij.platform.lsp.api.LspServerSupportProvider +import com.intellij.platform.lsp.api.lsWidget.LspServerWidgetItem +import com.intellij.util.io.BaseOutputReader +import com.intellij.util.messages.Topic +import xyz.block.ftl.intellij.toolWindow.FTLMessagesToolWindowFactory.Util.displayMessageInToolWindow +import java.util.regex.Pattern + +interface FTLLSPNotifier { + fun lspServerStateChange(state: LspServerState) + + companion object { + @Topic.ProjectLevel + val SERVER_STATE_CHANGE_TOPIC: Topic = Topic.create( + "FTL Server State Changed", + FTLLSPNotifier::class.java + ) + } +} + +class FTLLspServerSupportProvider : LspServerSupportProvider { + private var listenerAdded: Boolean = false + + override fun createLspServerWidgetItem(lspServer: LspServer, currentFile: VirtualFile?): LspServerWidgetItem = + LspServerWidgetItem( + lspServer = lspServer, + currentFile = currentFile, + settingsPageClass = FTLSettingsConfigurable::class.java, + widgetMainActionBaseIcon = Icons.Ide.MenuArrow + ) + + override fun fileOpened( + project: Project, + file: VirtualFile, + serverStarter: LspServerSupportProvider.LspServerStarter + ) { + if (!listenerAdded) { + try { + listenerAdded = true + val lspServerManager = LspServerManager.getInstance(project) + lspServerManager.addLspServerManagerListener(listener = object : LspServerManagerListener { + override fun serverStateChanged(lspServer: LspServer) { + val publisher = project.messageBus.syncPublisher(FTLLSPNotifier.SERVER_STATE_CHANGE_TOPIC) + publisher.lspServerStateChange(lspServer.state) + } + }, parentDisposable = { }, sendEventsForExistingServers = true) + } catch (e: Exception) { + listenerAdded = false + } + } + + val isFtlSupportLanguage = file.extension == "go" || file.extension == "kt" || file.extension == "java" + if (isFtlSupportLanguage && hasFtlProjectFile(project)) { + serverStarter.ensureServerStarted(FTLLspServerDescriptor(project)) + } + } + + private fun hasFtlProjectFile(project: Project): Boolean { + val projectBaseDir = project.baseDir ?: return false + val ftlProjectFile = projectBaseDir.findChild("ftl-project.toml") + return ftlProjectFile != null && ftlProjectFile.exists() + } + + fun startLspServer(project: Project) { + val lspServerManager = LspServerManager.getInstance(project) + lspServerManager.startServersIfNeeded(FTLLspServerSupportProvider::class.java) + } + + fun stopLspServer(project: Project): OSProcessHandler? { + return when (getLspServerStatus(project)) { + LspServerState.ShutdownUnexpectedly -> { + stopViaCommand(project) + } + + else -> { + val lspServerManager = LspServerManager.getInstance(project) + lspServerManager.stopServers(FTLLspServerSupportProvider::class.java) + null + } + } + } + + private fun stopViaCommand(project: Project): OSProcessHandler { + val settings = AppSettings.getInstance().state + val generalCommandLine = + GeneralCommandLine(listOf(settings.lspServerPath) + settings.lspServerStopArguments.split(Pattern.compile("\\s+"))).withCharset( + Charsets.UTF_8 + ) + generalCommandLine.setWorkDirectory(project.basePath) + displayMessageInToolWindow(project, "LSP Server Command: " + generalCommandLine.commandLineString) + displayMessageInToolWindow(project, "Working Directory: " + generalCommandLine.workDirectory) + + LOG.info("$this: stopping LSP server: $generalCommandLine") + val process: OSProcessHandler = object : OSProcessHandler(generalCommandLine) { + override fun readerOptions(): BaseOutputReader.Options = BaseOutputReader.Options.forMostlySilentProcess() + } + + return process + } + + fun restartLspServer(project: Project) { + val lspServerManager = LspServerManager.getInstance(project) + lspServerManager.stopAndRestartIfNeeded(FTLLspServerSupportProvider::class.java) + } + + fun getLspServerStatus(project: Project): LspServerState { + val lspServerManager = LspServerManager.getInstance(project) + val server = lspServerManager.getServersForProvider(FTLLspServerSupportProvider::class.java).firstOrNull() + + return server?.state ?: LspServerState.ShutdownNormally + } +} diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettings.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettings.kt new file mode 100644 index 0000000000..9f59376afc --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettings.kt @@ -0,0 +1,39 @@ +package xyz.block.ftl.intellij + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import org.jetbrains.annotations.NonNls + +@State( + name = "org.intellij.sdk.settings.AppSettings", + storages = [Storage("SdkSettingsPlugin.xml")] +) +@Service +class AppSettings : PersistentStateComponent { + + data class State( + @NonNls var lspServerPath: String = "ftl", + var lspServerArguments: String = "--recreate --lsp", + var lspServerStopArguments: String = "serve --stop", + var autoRestartLspServer: Boolean = false, + ) + + private var myState = State() + + companion object { + fun getInstance(): AppSettings { + return ApplicationManager.getApplication().getService(AppSettings::class.java) + } + } + + override fun getState(): State { + return myState + } + + override fun loadState(state: State) { + myState = state + } +} diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettingsComponent.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettingsComponent.kt new file mode 100644 index 0000000000..9fcda2db49 --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettingsComponent.kt @@ -0,0 +1,57 @@ +package xyz.block.ftl.intellij + +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.FormBuilder +import javax.swing.JComponent +import javax.swing.JPanel + +class FTLSettingsComponent { + + private val settingsPanel: JPanel + private val lspServerPath = TextFieldWithBrowseButton() + private val lspServerArguments = JBTextField() + private val lspServerStopArguments = JBTextField() + + init { + settingsPanel = FormBuilder.createFormBuilder() + .addLabeledComponent(JBLabel("LSP Server Path:"), lspServerPath, 1, false) + .addLabeledComponent(JBLabel("LSP Server Start Arguments:"), lspServerArguments, 1, false) + .addLabeledComponent(JBLabel("LSP Server Stop Arguments:"), lspServerStopArguments, 1, false) + .addComponentFillVertically(JPanel(), 0) + .panel + } + + fun getPanel(): JPanel { + return settingsPanel + } + + fun getPreferredFocusedComponent(): JComponent { + return lspServerPath + } + + fun getLspServerPath(): String { + return lspServerPath.text + } + + fun setLspServerPath(newPath: String) { + lspServerPath.text = newPath + } + + fun getLspServerArguments(): String { + return lspServerArguments.text + } + + fun getLspServerStopArguments(): String { + return lspServerStopArguments.text + } + + fun setLspServerArguments(newArguments: String) { + lspServerArguments.text = newArguments + } + + fun setLspServerStopArguments(newArguments: String) { + lspServerStopArguments.text = newArguments + } +} diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettingsConfigurable.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettingsConfigurable.kt new file mode 100644 index 0000000000..689c367ee6 --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/FTLSettingsConfigurable.kt @@ -0,0 +1,49 @@ +package xyz.block.ftl.intellij + +import com.intellij.openapi.options.Configurable +import org.jetbrains.annotations.Nls +import javax.swing.JComponent + +class FTLSettingsConfigurable : Configurable { + + private var mySettingsComponent: FTLSettingsComponent? = null + + @Nls(capitalization = Nls.Capitalization.Title) + override fun getDisplayName(): String { + return "FTL" + } + + override fun getPreferredFocusedComponent(): JComponent? { + return mySettingsComponent?.getPreferredFocusedComponent() + } + + override fun createComponent(): JComponent? { + mySettingsComponent = FTLSettingsComponent() + return mySettingsComponent?.getPanel() + } + + override fun isModified(): Boolean { + val state = AppSettings.getInstance().state + return mySettingsComponent?.getLspServerPath() != state.lspServerPath || + mySettingsComponent?.getLspServerArguments() != state.lspServerArguments || + mySettingsComponent?.getLspServerStopArguments() != state.lspServerStopArguments + } + + override fun apply() { + val state = AppSettings.getInstance().state + state.lspServerPath = mySettingsComponent?.getLspServerPath() ?: "ftl" + state.lspServerArguments = mySettingsComponent?.getLspServerArguments() ?: "--recreate --lsp" + state.lspServerStopArguments = mySettingsComponent?.getLspServerStopArguments() ?: "serve --stop" + } + + override fun reset() { + val state = AppSettings.getInstance().state + mySettingsComponent?.setLspServerPath(state.lspServerPath) + mySettingsComponent?.setLspServerArguments(state.lspServerArguments) + mySettingsComponent?.setLspServerStopArguments(state.lspServerStopArguments) + } + + override fun disposeUIResources() { + mySettingsComponent = null + } +} diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/toolWindow/FTLMessagesPanel.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/toolWindow/FTLMessagesPanel.kt new file mode 100644 index 0000000000..3c5ab18cc5 --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/toolWindow/FTLMessagesPanel.kt @@ -0,0 +1,33 @@ +package xyz.block.ftl.intellij.toolWindow + +import com.intellij.execution.filters.TextConsoleBuilderFactory +import com.intellij.execution.ui.ConsoleView +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.SimpleToolWindowPanel +import xyz.block.ftl.intellij.runOnEDT +import java.awt.BorderLayout +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +class FTLMessagesPanel(project: Project) : SimpleToolWindowPanel(false, false) { + val consoleView: ConsoleView = TextConsoleBuilderFactory.getInstance().createBuilder(project).console + var autoScrollEnabled = true + + init { + layout = BorderLayout() + add(consoleView.component, BorderLayout.CENTER) + } + + fun addMessage(message: String) { + runOnEDT { + val timestamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + val messageWithTimestamp = "[$timestamp] $message\n" + consoleView.print(messageWithTimestamp, ConsoleViewContentType.NORMAL_OUTPUT) + + if (autoScrollEnabled) { + consoleView.requestScrollingToEnd() + } + } + } +} diff --git a/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/toolWindow/FTLToolWindowFactory.kt b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/toolWindow/FTLToolWindowFactory.kt new file mode 100644 index 0000000000..4241fa9e4a --- /dev/null +++ b/extensions/intellij/src/main/kotlin/xyz/block/ftl/intellij/toolWindow/FTLToolWindowFactory.kt @@ -0,0 +1,220 @@ +package xyz.block.ftl.intellij.toolWindow + +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.ui.ConsoleView +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.platform.lsp.api.LspServerState +import com.intellij.ui.content.ContentFactory +import com.intellij.ui.content.ContentManagerEvent +import com.intellij.ui.content.ContentManagerListener +import xyz.block.ftl.intellij.FTLLSPNotifier +import xyz.block.ftl.intellij.FTLLspServerService +import xyz.block.ftl.intellij.FTLSettingsConfigurable +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService + +class FTLMessagesToolWindowFactory() : ToolWindowFactory, DumbAware { + private var currentLspState: LspServerState = LspServerState.Initializing + val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + + private lateinit var startAction: AnAction + private lateinit var stopAction: AnAction + + private lateinit var panel: FTLMessagesPanel + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + panel = FTLMessagesPanel(project) + + val contentFactory = ContentFactory.getInstance() + val actionManager = ActionManager.getInstance() + val content = contentFactory.createContent(panel, "", false) + + val actionGroup = DefaultActionGroup().apply { + + startAction = object : DumbAwareAction("Start", "Start the process", AllIcons.Actions.Execute) { + override fun actionPerformed(e: AnActionEvent) { + panel.addMessage("Start action triggered") + + val service = FTLLspServerService.getInstance(project) + panel.addMessage("Status is: ${service.lspServerSupportProvider.getLspServerStatus(project)}") + service.lspServerSupportProvider.startLspServer(project) + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = + LspServerState.Running != currentLspState && LspServerState.Initializing != currentLspState + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + } + add(startAction) + + stopAction = object : DumbAwareAction("Stop", "Stop the process", AllIcons.Actions.Suspend) { + override fun actionPerformed(e: AnActionEvent) { + panel.addMessage("Stop action triggered") + val service = FTLLspServerService.getInstance(project) + panel.addMessage("Status is: ${service.lspServerSupportProvider.getLspServerStatus(project)}") + val processHandler = service.lspServerSupportProvider.stopLspServer(project) + processHandler?.addProcessListener(object : ProcessAdapter() { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + val message = event.text.trim() + if (message.isNotBlank()) { + Util.displayMessageInToolWindow(project, message) + } + } + }) + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = LspServerState.ShutdownNormally != currentLspState + } + } + add(stopAction) + + add(object : DumbAwareAction("Restart", "Restart the process", AllIcons.Actions.Restart) { + override fun actionPerformed(e: AnActionEvent) { + panel.addMessage("Restart action triggered") + val service = FTLLspServerService.getInstance(project) + panel.addMessage("Status is: ${service.lspServerSupportProvider.getLspServerStatus(project)}") + service.lspServerSupportProvider.restartLspServer(project) + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + }) + + add(object : AnAction("Toggle Soft Wrap", "Toggle soft wrap in console view", AllIcons.Actions.ToggleSoftWrap) { + override fun actionPerformed(e: AnActionEvent) { + val editor = getEditorFromConsoleView(panel.consoleView) + if (editor != null) { + val settings = editor.settings + settings.isUseSoftWraps = !settings.isUseSoftWraps + } + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + + private fun getEditorFromConsoleView(consoleView: ConsoleView): EditorEx? { + return try { + val method = consoleView.javaClass.getMethod("getEditor") + method.isAccessible = true + method.invoke(consoleView) as? EditorEx + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + override fun update(e: AnActionEvent) { + val editor = getEditorFromConsoleView(panel.consoleView) + if (editor != null) { + val isSoftWrapEnabled = editor.settings.isUseSoftWraps + e.presentation.isEnabled = true + e.presentation.putClientProperty("selected", isSoftWrapEnabled) + } else { + e.presentation.isEnabled = false + } + } + }) + + add(object : AnAction("Clear", "Clear the console", AllIcons.Actions.GC) { + override fun actionPerformed(e: AnActionEvent) { + panel.consoleView.clear() + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + }) + + add(object : ToggleAction("Auto Scroll", "Toggle auto scroll", AllIcons.RunConfigurations.Scroll_down) { + override fun isSelected(e: AnActionEvent): Boolean { + return panel.autoScrollEnabled + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + + override fun setSelected(e: AnActionEvent, state: Boolean) { + panel.autoScrollEnabled = state + } + }) + + add(object : AnAction("Settings", "Open Settings", AllIcons.General.Settings) { + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.EDT + } + + override fun actionPerformed(e: AnActionEvent) { + ShowSettingsUtil.getInstance().showSettingsDialog(project, FTLSettingsConfigurable::class.java) + } + }) + } + + val actionToolbar: ActionToolbar = actionManager.createActionToolbar("FTLToolbar", actionGroup, true) + + actionToolbar.targetComponent = panel.toolbar + panel.toolbar = actionToolbar.component + toolWindow.contentManager.addContent(content) + + project.messageBus.connect().subscribe( + FTLLSPNotifier.SERVER_STATE_CHANGE_TOPIC, + object : FTLLSPNotifier { + override fun lspServerStateChange(state: LspServerState) { + currentLspState = state + panel.addMessage("State changed: ${state}") + } + }) + } + + override fun init(toolWindow: ToolWindow) { + toolWindow.contentManager.addContentManagerListener(object : ContentManagerListener { + override fun contentRemoveQuery(event: ContentManagerEvent) { + scheduler.shutdown() + super.contentRemoveQuery(event) + } + }) + } + + object Util { + fun displayMessageInToolWindow(project: Project, message: String) { + ApplicationManager.getApplication().invokeLater { + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("FTL") + if (toolWindow != null) { + val content = toolWindow.contentManager.getContent(0) + val panel = content?.component as? FTLMessagesPanel + panel?.addMessage(message) + } + } + } + } +} + diff --git a/extensions/intellij/src/main/resources/META-INF/plugin.xml b/extensions/intellij/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000000..3ae75ceb71 --- /dev/null +++ b/extensions/intellij/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,48 @@ + + + + xyz.block.ftl.intellij + + + FTL + + + FTL + + + + + + com.intellij.modules.platform + com.intellij.modules.ultimate + + + + + + + + + + + + + diff --git a/extensions/intellij/src/main/resources/META-INF/pluginIcon.svg b/extensions/intellij/src/main/resources/META-INF/pluginIcon.svg new file mode 100644 index 0000000000..ad741c90d7 --- /dev/null +++ b/extensions/intellij/src/main/resources/META-INF/pluginIcon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file