Skip to content

Commit

Permalink
feat: initial Intelij plugin (#2564)
Browse files Browse the repository at this point in the history
Thanks to @bradleydwyer 

I have tested it out and added Hermit support so we run FTL with the
right env.

It's not perfect but I think we should just get this in an iterate. 

#2549

---------

Co-authored-by: Bradley Dwyer <bradleydwyer@tbd.email>
  • Loading branch information
stuartwdouglas and bradleydwyer authored Sep 2, 2024
1 parent 76d4b9e commit eb2bd33
Show file tree
Hide file tree
Showing 17 changed files with 782 additions and 0 deletions.
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,23 @@ jobs:
run: pnpm run lint
- name: VSCode extension pnpm build
run: just build-extension
plugin:
name: Intellij Plugin
if: github.event_name != 'pull_request' || github.event.action == 'enqueued' || contains( github.event.pull_request.labels.*.name, 'run-all')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Init Hermit
uses: cashapp/activate-hermit@v1
with:
cache: true
- name: Build Cache
uses: ./.github/actions/build-cache
- name: Install Java
run: java -version
- name: Build Intellij Plugin
run: just build-intellij-plugin
build-all:
name: Rebuild All
if: github.event_name != 'pull_request' || github.event.action == 'enqueued' || contains( github.event.pull_request.labels.*.name, 'run-all')
Expand Down
3 changes: 3 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ package-extension: build-extension
publish-extension: package-extension
@cd extensions/vscode && vsce publish

build-intellij-plugin:
@cd extensions/intellij && gradle buildPlugin

# Kotlin runtime is temporarily disabled; these instructions create a dummy zip in place of the kotlin runtime jar for
# the runner.
build-kt-runtime:
Expand Down
47 changes: 47 additions & 0 deletions extensions/intellij/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<JavaCompile> {
sourceCompatibility = "17"
targetCompatibility = "17"
}
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.jvmTarget = "17"
}

patchPluginXml {
sinceBuild.set("241")
untilBuild.set("242.*")
}

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"))
}
}
8 changes: 8 additions & 0 deletions extensions/intellij/gradle.properties
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions extensions/intellij/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}

rootProject.name = "intellij"
Original file line number Diff line number Diff line change
@@ -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`)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package xyz.block.ftl.intellij

import com.intellij.openapi.application.ApplicationManager

fun runOnEDT(runnable: () -> Unit) {
ApplicationManager.getApplication().invokeLater {
runnable()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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.ide.DataManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.platform.lsp.api.LspServerNotificationsHandler
import com.intellij.platform.lsp.api.ProjectWideLspServerDescriptor
import com.intellij.tools.ToolsCustomizer
import xyz.block.ftl.intellij.toolWindow.FTLMessagesToolWindowFactory
import java.util.concurrent.CompletableFuture
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)
try {
// Hermit support, we need to get the environment variables so we use the correct FTL
val result = CompletableFuture<GeneralCommandLine>()
runOnEDT {
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("FTL")
if (toolWindow != null) {
val dataContext = DataManager.getInstance().getDataContext(toolWindow.component)
val customizeCommandLine =
ToolsCustomizer.customizeCommandLine(generalCommandLine, dataContext)
result.complete(customizeCommandLine)
}
}
val res = result.get()
return if (res != null) res else generalCommandLine
} catch (e: Exception) {
displayMessageInToolWindow("Failed to customize LSP Server Command: " + e.message)
}
return generalCommandLine
}

override fun startServerProcess(): OSProcessHandler {
displayMessageInToolWindow("Starting FTL LSP Server")
val processHandler = super.startServerProcess()
processHandler.addProcessListener(object : ProcessAdapter() {

override fun startNotified(event: ProcessEvent) {
super.startNotified(event)
displayMessageInToolWindow("LSP Started")
}

override fun processTerminated(event: ProcessEvent) {
super.processTerminated(event)
displayMessageInToolWindow("LSP Terminated")
}

override fun processWillTerminate(event: ProcessEvent, willBeDestroyed: Boolean) {
super.processWillTerminate(event, willBeDestroyed)
displayMessageInToolWindow("LSP Will Terminate")
}

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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<FTLLSPNotifier> = 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
}
}
Original file line number Diff line number Diff line change
@@ -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<AppSettings.State> {

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
}
}
Loading

0 comments on commit eb2bd33

Please # to comment.