Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial Intelij plugin #2564

Merged
merged 2 commits into from
Sep 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How long does this take? Copy pasta the if statement from other jobs if this is going to take more than a minute or two, so we can keep the main PR loop fast.

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
Loading