From a11d78f9e93d268fe8466c5aac7ba7a463d09c72 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Wed, 2 Oct 2024 00:27:04 +0800 Subject: [PATCH] fix(intellj): update chat panel for compatible with v0.18.0. (#3227) * fix(intellj): update chat panel for compatible with v0.18.0. * chore(intellij): bump version to 1.8.0-dev. * fix(intellij): fix show message when server disconnected. --- clients/intellij/build.gradle.kts | 3 +- .../tabbyml/intellijtabby/chat/ChatBrowser.kt | 836 ++++++++++++------ .../completion/InlineCompletionService.kt | 1 - .../intellijtabby/events/CombinedState.kt | 24 +- .../intellijtabby/lsp/LanguageClient.kt | 7 + .../widgets/ChatToolWindowFactory.kt | 4 +- .../src/main/resources/styles/chat-panel.css | 48 - 7 files changed, 601 insertions(+), 322 deletions(-) delete mode 100644 clients/intellij/src/main/resources/styles/chat-panel.css diff --git a/clients/intellij/build.gradle.kts b/clients/intellij/build.gradle.kts index 72469dfd5f9..13c858ffc7a 100644 --- a/clients/intellij/build.gradle.kts +++ b/clients/intellij/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { instrumentationTools() } implementation("org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1") + implementation("io.github.z4kn4fein:semver:2.0.0") } tasks { @@ -38,7 +39,7 @@ tasks { intellijPlatform { pluginConfiguration { - version.set("1.7.0-dev") + version.set("1.8.0-dev") changeNotes.set(provider { changelog.renderItem( changelog.getLatest(), diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/chat/ChatBrowser.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/chat/ChatBrowser.kt index c6747e30dec..274028a1975 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/chat/ChatBrowser.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/chat/ChatBrowser.kt @@ -1,103 +1,75 @@ package com.tabbyml.intellijtabby.chat import com.google.gson.Gson -import com.google.gson.JsonParser -import com.intellij.openapi.components.serviceOrNull +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import com.intellij.ide.ui.UISettingsListener +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.colors.EditorColors import com.intellij.openapi.editor.colors.EditorColorsManager -import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer +import com.intellij.openapi.project.guessProjectDir import com.intellij.ui.jcef.JBCefBrowser import com.intellij.ui.jcef.JBCefBrowserBase import com.intellij.ui.jcef.JBCefJSQuery -import com.intellij.util.ui.UIUtil -import com.tabbyml.intellijtabby.lsp.ConnectionService -import com.tabbyml.intellijtabby.lsp.LanguageClient +import com.tabbyml.intellijtabby.events.CombinedState +import com.tabbyml.intellijtabby.git.GitProvider +import com.tabbyml.intellijtabby.lsp.protocol.ServerInfo import com.tabbyml.intellijtabby.lsp.protocol.Status -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.future.await -import kotlinx.coroutines.launch +import io.github.z4kn4fein.semver.Version +import io.github.z4kn4fein.semver.constraints.Constraint +import io.github.z4kn4fein.semver.constraints.satisfiedBy import org.cef.browser.CefBrowser import org.cef.handler.CefLoadHandlerAdapter import java.awt.Color -import java.util.* +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.io.File + class ChatBrowser(private val project: Project) { - private var isChatPageDisplayed = false + private val logger = Logger.getInstance(ChatBrowser::class.java) + private val gson = Gson() + private val combinedState = project.service() + private val gitProvider = project.service() private val messageBusConnection = project.messageBus.connect() - private val scope = CoroutineScope(Dispatchers.IO) - private val browser: JBCefBrowser = JBCefBrowser.createBuilder() - .setOffScreenRendering(true) // On Mac, setting false will leave a white flash when opening the window - .build() - private suspend fun getServer() = project.serviceOrNull()?.getServerAsync() + private val browser = JBCefBrowser() + private val reloadHandler = JBCefJSQuery.create(browser as JBCefBrowserBase) + private val chatPanelRequestHandler = JBCefJSQuery.create(browser as JBCefBrowserBase) - data class DisplayChatPageOptions(val force: Boolean = false) + private var currentConfig: ServerInfo.ServerInfoConfig? = null - init { - messageBusConnection.subscribe(LanguageClient.AgentListener.TOPIC, object : LanguageClient.AgentListener { - override fun agentStatusChanged(status: String) { - if (status == Status.DISCONNECTED) { - displayDisconnectedPage() - } else { - scope.launch { - val server = getServer() ?: return@launch - val serverInfo = server.agentFeature.serverInfo().await() - displayChatPage(serverInfo.config.endpoint) - refreshChatPage() - } - } - } - }) + val browserComponent = browser.component - // Listen to the message sent from the web page - val jsQuery = JBCefJSQuery.create(browser as JBCefBrowserBase) - jsQuery.addHandler { message: String -> - val jsonElement = JsonParser.parseString(message) - when { - jsonElement.isJsonObject -> { - val json = jsonElement.asJsonObject - val action = json.get("action")?.asString - if (action == "rendered") { - this.refreshChatPage() - return@addHandler JBCefJSQuery.Response("") - } - } + private data class ChatPanelRequest( + val method: String, + val params: List, + ) - // FIXME: Refactor thread-receiving implementation - jsonElement.isJsonArray -> { - val jsonArray = jsonElement.asJsonArray // [commandNumber, [id, functionName, args]] - if (jsonArray.size() >= 2) { - try { - val command = jsonArray[0].asInt - if (command == 0 && jsonArray[1].isJsonArray) { - val commandArray = jsonArray[1].asJsonArray // [id, functionName, args] - if (commandArray.size() >= 3) { - val functionName = commandArray[1].asString - when (functionName) { - "refresh" -> { - scope.launch { - val server = getServer() ?: return@launch - val serverInfo = server.agentFeature.serverInfo().await() - displayChatPage(serverInfo.config.endpoint, DisplayChatPageOptions(force = true)) - } - } - } - } - } - } catch (e: Exception) { - return@addHandler JBCefJSQuery.Response("Error: ${e.message}") - } - } - } - } + private data class FileContext( + val kind: String = "file", + val range: LineRange, + val filepath: String, + val content: String, + @SerializedName("git_url") + val gitUrl: String, + ) { + data class LineRange( + val start: Int, + val end: Int, + ) + } - JBCefJSQuery.Response("") - } + init { + browserComponent.isVisible = false - // Inject window.onReceiveMessage into browser's JS context after HTML load - // Enables web page to send messages to IntelliJ plugin - window.onReceiveMessage(message) browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { override fun onLoadingStateChange( browser: CefBrowser?, @@ -105,224 +77,560 @@ class ChatBrowser(private val project: Project) { canGoBack: Boolean, canGoForward: Boolean ) { - if (!isLoading) { - val script = """window.onReceiveMessage = function(message) { - ${jsQuery.inject("message")} - }""".trimIndent() - browser?.executeJavaScript( - script, - browser.url, - 0 - ) + if (browser != null && !isLoading) { + handleLoaded() } } }, browser.cefBrowser) - // FIXME: Implement web server health detection to display the disconnected page if the server is down. - // Note: Currently, this.combinedState.state.agentStatus is always NOT_INITIALIZED at this point. - displayDisconnectedPage() - scope.launch { - val server = getServer() ?: return@launch - val serverInfo = server.agentFeature.serverInfo().await() - displayChatPage(serverInfo.config.endpoint) + reloadHandler.addHandler { + reloadContent(true) + return@addHandler JBCefJSQuery.Response("") } - Disposer.register(project, browser) + chatPanelRequestHandler.addHandler { message -> + val request = gson.fromJson(message, ChatPanelRequest::class.java) + handleChatPanelRequest(request) + return@addHandler JBCefJSQuery.Response("") + } + + this.browser.loadHTML(HTML_CONTENT) + + messageBusConnection.subscribe(CombinedState.Listener.TOPIC, object : CombinedState.Listener { + override fun stateChanged(state: CombinedState.State) { + reloadContent() + } + }) + + messageBusConnection.subscribe(UISettingsListener.TOPIC, UISettingsListener { + jsApplyStyle() + chatPanelUpdateTheme() + }) } - // FIXME - // listen to edit theme change and send sync-theme message to the HTML + private fun handleLoaded() { + jsInjectHandlers() + jsApplyStyle() + reloadContent() + browserComponent.isVisible = true + } - fun refreshChatPage() { - scope.launch { - val server = getServer() ?: return@launch - val agentStatus = server.agentFeature.status().await() - val serverInfo = server.agentFeature.serverInfo().await() + private val isDarkTheme get() = EditorColorsManager.getInstance().isDarkEditor - if (agentStatus == Status.UNAUTHORIZED || agentStatus == Status.NOT_INITIALIZED) { - sendMessageToServer( - "showError", - listOf(mapOf("content" to "Before you can start chatting, please take a moment to set up your credentials to connect to the Tabby server.")) - ) - return@launch + private fun buildCss(): String { + val editorColorsScheme = EditorColorsManager.getInstance().schemeForCurrentUITheme + val bgColor = editorColorsScheme.defaultBackground + val bgActiveColor = editorColorsScheme.getColor(EditorColors.CARET_ROW_COLOR) + ?: if (isDarkTheme) editorColorsScheme.defaultBackground.brighter() else editorColorsScheme.defaultBackground.darker() + val fgColor = editorColorsScheme.defaultForeground + val borderColor = editorColorsScheme.getColor(EditorColors.BORDER_LINES_COLOR) + ?: if (isDarkTheme) editorColorsScheme.defaultForeground.brighter() else editorColorsScheme.defaultForeground.darker() + val primaryColor = editorColorsScheme.getAttributes(EditorColors.REFERENCE_HYPERLINK_COLOR).foregroundColor + val font = editorColorsScheme.getFont(EditorFontType.PLAIN).fontName + val fontSize = editorColorsScheme.editorFontSize + val css = String.format("background-color: hsl(%s);", bgActiveColor.toHsl()) + + String.format("--background: %s;", bgColor.toHsl()) + + String.format("--foreground: %s;", fgColor.toHsl()) + + String.format("--border: %s;", borderColor.toHsl()) + + String.format("--primary: %s;", primaryColor.toHsl()) + + String.format("font: %s;", font) + + String.format("font-size: %spx;", fontSize) + + // FIXME(@icycodes): remove these once the server no longer reads the '--intellij-editor' css vars + String.format("--intellij-editor-background: %s;", bgColor.toHsl()) + + String.format("--intellij-editor-foreground: %s;", fgColor.toHsl()) + + String.format("--intellij-editor-border: %s;", borderColor.toHsl()) + logger.debug("CSS: $css") + return css + } + + private fun reloadContent(force: Boolean = false) { + if (force) { + // FIXME(@icycodes): force reload requires await reconnection then get server health + reloadContentInternal(true) + } else { + reloadContentInternal(false) + } + } + + private fun reloadContentInternal(force: Boolean = false) { + val status = combinedState.state.agentStatus + when (status) { + Status.NOT_INITIALIZED, Status.FINALIZED -> { + showContent("Initializing...") } - // FIXME - // Check for chat panel availability - // If the panel is not available, display an error message to the user + Status.DISCONNECTED -> { + showContent("Cannot connect to Tabby server, please check your settings.") + } + + Status.UNAUTHORIZED -> { + showContent("Authorization required, please set your token in settings.") + } - // FIXME: Refactor thread-sending implementation - sendMessageToServer("cleanError") - sendMessageToServer("init", listOf(mapOf("fetcherOptions" to mapOf("authorization" to serverInfo.config.token)))) + else -> { + val health = combinedState.state.agentServerInfo?.health + val error = checkServerHealth(health) + if (error != null) { + showContent(error) + } else { + val config = combinedState.state.agentServerInfo?.config + if (config != null && (force || currentConfig != config)) { + showContent("Loading Tabby chat panel...") + currentConfig = config + jsLoadChatPanel() + } + } + } } } - fun displayChatPage(endpoint: String, opts: DisplayChatPageOptions? = null) { - val cssContent = this::class.java.getResource("/styles/chat-panel.css")?.readText() ?: "" + private fun showContent(message: String? = null) { + if (message != null) { + jsShowMessage(message) + jsShowChatPanel(false) + } else { + jsShowMessage(null) + jsShowChatPanel(true) + } + } - val editorColorsManager = EditorColorsManager.getInstance() - val theme = if (editorColorsManager.isDarkEditor) "dark" else "light" - val editorColorsScheme = EditorColorsManager.getInstance().schemeForCurrentUITheme - val fontSize = editorColorsScheme.editorFontSize - val backgroundColor = editorColorsScheme.defaultBackground.toHex() - val foregroundColor = editorColorsScheme.defaultForeground.toHex() - val borderColor = if (theme == "dark") "444444" else "B9B9B9" + private fun handleChatPanelRequest(request: ChatPanelRequest) { + when (request.method) { + "navigate" -> { + logger.debug("navigate: request: ${request.params}") + // FIXME(@icycodes): not implemented yet + } - if (this.isChatPageDisplayed && opts?.force != true) return + "refresh" -> { + logger.debug("refresh: request: ${request.params}") + reloadContent(true) + } - this.isChatPageDisplayed = true - val htmlContent = """ - - - - - - - - - - + + + + + """ } -} \ No newline at end of file +} diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/completion/InlineCompletionService.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/completion/InlineCompletionService.kt index beed682fe6b..1da8fc55205 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/completion/InlineCompletionService.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/completion/InlineCompletionService.kt @@ -16,7 +16,6 @@ import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange -import com.intellij.util.messages.MessageBusConnection import com.intellij.util.messages.Topic import com.tabbyml.intellijtabby.events.CaretListener import com.tabbyml.intellijtabby.events.DocumentListener diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/events/CombinedState.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/events/CombinedState.kt index eb2526bbe26..56b794aa929 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/events/CombinedState.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/events/CombinedState.kt @@ -9,6 +9,7 @@ import com.tabbyml.intellijtabby.completion.InlineCompletionService import com.tabbyml.intellijtabby.lsp.ConnectionService import com.tabbyml.intellijtabby.lsp.LanguageClient import com.tabbyml.intellijtabby.lsp.protocol.IssueList +import com.tabbyml.intellijtabby.lsp.protocol.ServerInfo import com.tabbyml.intellijtabby.lsp.protocol.Status import com.tabbyml.intellijtabby.notifications.notifyAuthRequired import com.tabbyml.intellijtabby.safeSyncPublisher @@ -23,30 +24,35 @@ class CombinedState(private val project: Project) : Disposable { val connectionState: ConnectionService.State, val agentStatus: String, val agentIssue: String?, + val agentServerInfo: ServerInfo?, val isInlineCompletionLoading: Boolean, ) { fun withSettings(settings: SettingsService.Settings): State { - return State(settings, connectionState, agentStatus, agentIssue, isInlineCompletionLoading) + return State(settings, connectionState, agentStatus, agentIssue, agentServerInfo, isInlineCompletionLoading) } fun withConnectionState(connectionState: ConnectionService.State): State { - return State(settings, connectionState, agentStatus, agentIssue, isInlineCompletionLoading) + return State(settings, connectionState, agentStatus, agentIssue, agentServerInfo, isInlineCompletionLoading) } fun withAgentStatus(agentStatus: String): State { - return State(settings, connectionState, agentStatus, agentIssue, isInlineCompletionLoading) + return State(settings, connectionState, agentStatus, agentIssue, agentServerInfo, isInlineCompletionLoading) } fun withAgentIssue(currentIssue: String?): State { - return State(settings, connectionState, agentStatus, currentIssue, isInlineCompletionLoading) + return State(settings, connectionState, agentStatus, currentIssue, agentServerInfo, isInlineCompletionLoading) } fun withoutAgentIssue(): State { return withAgentIssue(null) } + fun withAgentServerInfo(serverInfo: ServerInfo?): State { + return State(settings, connectionState, agentStatus, agentIssue, serverInfo, isInlineCompletionLoading) + } + fun withInlineCompletionLoading(isInlineCompletionLoading: Boolean = true): State { - return State(settings, connectionState, agentStatus, agentIssue, isInlineCompletionLoading) + return State(settings, connectionState, agentStatus, agentIssue, agentServerInfo, isInlineCompletionLoading) } } @@ -55,7 +61,8 @@ class CombinedState(private val project: Project) : Disposable { ConnectionService.State.INITIALIZING, Status.NOT_INITIALIZED, null, - false + null, + false, ) private set @@ -88,6 +95,11 @@ class CombinedState(private val project: Project) : Disposable { } ?: state.withoutAgentIssue() project.safeSyncPublisher(Listener.TOPIC)?.stateChanged(state) } + + override fun agentServerInfoUpdated(serverInfo: ServerInfo) { + state = state.withAgentServerInfo(serverInfo) + project.safeSyncPublisher(Listener.TOPIC)?.stateChanged(state) + } }) messageBusConnection.subscribe(InlineCompletionService.Listener.TOPIC, object : InlineCompletionService.Listener { diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/lsp/LanguageClient.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/lsp/LanguageClient.kt index 10b6f01f37f..e6f453ddfab 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/lsp/LanguageClient.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/lsp/LanguageClient.kt @@ -19,6 +19,7 @@ import com.tabbyml.intellijtabby.lsp.protocol.ClientCapabilities import com.tabbyml.intellijtabby.lsp.protocol.ClientInfo import com.tabbyml.intellijtabby.lsp.protocol.InitializeParams import com.tabbyml.intellijtabby.lsp.protocol.InitializeResult +import com.tabbyml.intellijtabby.lsp.protocol.ServerInfo import com.tabbyml.intellijtabby.lsp.protocol.TextDocumentClientCapabilities import com.tabbyml.intellijtabby.lsp.protocol.server.LanguageServer import com.tabbyml.intellijtabby.safeSyncPublisher @@ -82,6 +83,7 @@ class LanguageClient(private val project: Project) : com.tabbyml.intellijtabby.l scope.launch { project.safeSyncPublisher(AgentListener.TOPIC)?.agentStatusChanged(server.agentFeature.status().await()) project.safeSyncPublisher(AgentListener.TOPIC)?.agentIssueUpdated(server.agentFeature.issues().await()) + project.safeSyncPublisher(AgentListener.TOPIC)?.agentServerInfoUpdated(server.agentFeature.serverInfo().await()) } } @@ -93,6 +95,10 @@ class LanguageClient(private val project: Project) : com.tabbyml.intellijtabby.l project.safeSyncPublisher(AgentListener.TOPIC)?.agentIssueUpdated(params) } + override fun didUpdateServerInfo(params: DidUpdateServerInfoParams) { + project.safeSyncPublisher(AgentListener.TOPIC)?.agentServerInfoUpdated(params.serverInfo) + } + override fun editorOptions(params: EditorOptionsParams): CompletableFuture { val codeStyleSettingsManager = CodeStyleSettingsManager.getInstance(project) val indentation = findPsiFile(params.uri)?.language?.let { @@ -186,6 +192,7 @@ class LanguageClient(private val project: Project) : com.tabbyml.intellijtabby.l interface AgentListener { fun agentStatusChanged(status: String) {} fun agentIssueUpdated(issueList: IssueList) {} + fun agentServerInfoUpdated(serverInfo: ServerInfo) {} companion object { @Topic.ProjectLevel diff --git a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/widgets/ChatToolWindowFactory.kt b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/widgets/ChatToolWindowFactory.kt index 6881ed4b137..8eaf6e3daed 100644 --- a/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/widgets/ChatToolWindowFactory.kt +++ b/clients/intellij/src/main/kotlin/com/tabbyml/intellijtabby/widgets/ChatToolWindowFactory.kt @@ -9,8 +9,8 @@ import com.tabbyml.intellijtabby.chat.ChatBrowser class ChatToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val browserComponent = ChatBrowser(project).getBrowserComponent() - val content = ContentFactory.getInstance().createContent(browserComponent, "", false) + val browser = ChatBrowser(project) + val content = ContentFactory.getInstance().createContent(browser.browserComponent, "", false) toolWindow.contentManager.addContent(content) } diff --git a/clients/intellij/src/main/resources/styles/chat-panel.css b/clients/intellij/src/main/resources/styles/chat-panel.css deleted file mode 100644 index c4968bbfd7e..00000000000 --- a/clients/intellij/src/main/resources/styles/chat-panel.css +++ /dev/null @@ -1,48 +0,0 @@ -html, -body { - background: transparent; -} -html, -body, -iframe { - padding: 0; - margin: 0; - box-sizing: border-box; - overflow: hidden; -} -iframe { - border-width: 0; - width: 100%; - height: 100vh; -} - -/* Static content page */ -.static-content { - padding: 0.65rem 1.2rem; -} - -.static-content .avatar { - display: flex; - align-items: center; -} - -.static-content .avatar img { - width: 1rem; - height: 1rem; - object-fit: contain; - border-radius: 100%; - margin-right: 0.4rem; - padding: 0.2rem; - border: 1px solid var(--vscode-editorWidget-border); - background-color: rgb(232, 226, 210); -} - -.static-content .title { - margin: 0.45rem 0 0; - font-size: 0.85rem; -} - -.static-content p { - line-height: 1.45; - margin: 0.45rem 0; -}