diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 08c39f6..f454fc9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -96,6 +96,9 @@ jobs: run: > ./gradlew test --tests "org.zoocode.jetbrains.terminal.RawOutputTtyConnectorTest" + --tests "org.zoocode.jetbrains.util.JcefSupportTest" + --tests "org.zoocode.jetbrains.descriptor.PluginDescriptorTest" + --tests "org.zoocode.jetbrains.terminal.TerminalPtyBuilderTest" --no-configuration-cache - name: Build the plugin diff --git a/.gitignore b/.gitignore index 18bd871..ffafd14 100644 --- a/.gitignore +++ b/.gitignore @@ -52,10 +52,11 @@ deps/vscode/* jetbrains_plugin/plugins jetbrains_plugin/build .gradle +/.gradle-user-home/ deps/vsix/package/* deps/vsix/plugin/* -e # Roo build artifacts .zoo-build/ dist-with-roo/ -plans/ \ No newline at end of file +plans/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f550b04 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## Unreleased + +### Terminal on Windows + +- The plugin requests ConPTY explicitly through the public pty4j setter. The JetBrains registry key `terminal.use.conpty.on.windows` is no longer read. +- If the ConPTY native library fails to load, the process falls back to winpty and the session continues. +- Other process start failures propagate to the terminal error handling. diff --git a/README.md b/README.md index 7c61409..9748cbe 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ The plugin targets IntelliJ Platform build **233** (JetBrains 2023.3) or newer. - Android Studio 2026.1.4 or newer when using Android Studio; install a preview build until this version reaches the stable channel - A JCEF-enabled IDE runtime, which Zoo Code requires to render its interface +Where JCEF support comes from depends on the IDE version. From IntelliJ Platform 2026.2 (build 262), JCEF ships as a separate bundled plugin. If that plugin is missing or disabled, Zoo Code shows a warning notification and the Zoo Code tool window cannot render. Enable the bundled JCEF plugin under **Settings β†’ Plugins β†’ Installed** and restart the IDE. On IDEs older than 2026.2, JCEF comes from the IDE runtime; see [Known Issues](docs/KNOWN_ISSUES.md). + On first launch the plugin automatically downloads a pinned, platform-specific Node.js runtime from [nodejs.org](https://nodejs.org) (checksum-verified) and uses it to run the extension host, so no local Node.js installation is required. If the download is unavailable, the plugin falls back to a bundled Node.js or a Node.js **20.6.0 or newer** found on your `PATH`. ### Building from source diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index a5593eb..9f9beb3 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -20,18 +20,18 @@ Android Studio's default startup runtime does not include JCEF capabilities, req 1. **Download a JCEF-Compatible JetBrains Runtime** Download a JetBrains Runtime (JBR) with JCEF support: - + πŸ‘‰ https://github.com/JetBrains/JetBrainsRuntime/releases - + Choose a release with: - `jbr_jcef` in the name - Correct architecture (osx-aarch64, linux-x64, or windows-x64) - + Example: ``` jbr_jcef-17.0.11-osx-aarch64-b1063.2.tar.gz ``` - + Unpack it somewhere, e.g.: ``` ~/jbr/jbr_jcef @@ -47,15 +47,15 @@ Android Studio's default startup runtime does not include JCEF capabilities, req 4. Select it and choose the folder where you extracted the JBR (e.g., ~/jbr/jbr_jcef). 3. **Restart Android Studio** - + After selecting the new runtime, Android Studio will prompt you to restart. Confirm, and it will relaunch with the new runtime. 4. **Verify the Runtime** - + Go to Help β†’ About to confirm the IDE is now running with the new JBR. The version should indicate JCEF support (e.g., JetBrains Runtime jbr-17.0.11+7-b1238.56-jcef). 5. **Revert if Needed** - + You can always use the same "Choose Boot Java Runtime for the IDE" action to switch back to the default runtime if needed. ##### Method 2: Using Edit Custom VM Options @@ -91,6 +91,27 @@ On Linux ARM platforms, JCEF may fail to initialize native processes properly, l #### Solutions No solution available at this time. +### 1.3 JCEF Is Not Available on IDE 2026.2 or Newer + +#### Problem Description + +From IntelliJ Platform 2026.2 (build 262), JetBrains packages JCEF as a separate bundled plugin with the id `com.intellij.modules.jcef`. JCEF is no longer part of the IDE platform. When this bundled plugin is not present, the Zoo Code view cannot render. + +#### Behavior + +The plugin does not crash. It logs the reason and shows a warning notification. The Zoo Code tool window stays empty. The IDE log shows the state in the startup line `JCEF: CLASSES_MISSING`. + +#### Affected Platforms +- IDEs based on IntelliJ Platform build 262 or newer without the bundled JCEF plugin + +#### Solutions + +1. Open **Settings β†’ Plugins β†’ Installed** and search for **JCEF**. +2. Enable the bundled JCEF plugin if it is disabled. +3. Restart the IDE. + +If the bundled JCEF plugin is not in the list, this IDE build does not ship JCEF, and the Zoo Code view cannot run in it. IDEs older than 2026.2 get JCEF from the runtime; see section 1.1. + --- ## 2. Node Related Issues diff --git a/jetbrains_plugin/build.gradle.kts b/jetbrains_plugin/build.gradle.kts index 809ff92..d58013b 100644 --- a/jetbrains_plugin/build.gradle.kts +++ b/jetbrains_plugin/build.gradle.kts @@ -154,7 +154,7 @@ fun Sync.prepareSandbox() { } depfile.readLines().let { it.forEach { line -> - depPatterns.add(line.substringAfterLast("node_modules/") + "/**") + depPatterns.add(line.replace('\\', '/').substringAfterLast("node_modules/") + "/**") } } @@ -363,17 +363,29 @@ tasks { // linked against the 233 platform jars already on the compile classpath; this task // never needs the 2026.3 IDE. The scan target is the instrumented jar in the // sandbox, the exact bytes that the Plugin Verifier checks and that the published - // zip packages. The marker output makes the task up-to-date when the sandbox and - // the checker are unchanged. + // zip packages. The task first runs the checker --self-test, which feeds it + // synthetic clean and banned jars, so a scanner regression fails this task. The + // marker output makes the task up-to-date when the sandbox and the checker are + // unchanged. register("verifyPackagedBytecode") { group = "verification" - description = "Reject packaged references to terminal APIs removed in IntelliJ 2026.3" + description = + "Reject packaged references to terminal APIs removed in IntelliJ 2026.3 and to internal LocalPtyOptions API" val checker = layout.projectDirectory.file("smoke/Smoke263DispatchCheck.java") inputs.files(prepareSandbox.map { it.destinationDir }) inputs.file(checker) outputs.file(layout.buildDirectory.file("verification/packaged-bytecode.ok")) dependsOn(prepareSandbox) doLast { + project.exec { + commandLine( + verificationJavaLauncher.get().executablePath.asFile.absolutePath, + "-cp", + configurations.getByName("compileClasspath").asPath, + checker.asFile.absolutePath, + "--self-test", + ) + } val pluginJar = packagedPluginJar() project.exec { commandLine( diff --git a/jetbrains_plugin/smoke/PowerShellIntegrationCheck.ps1 b/jetbrains_plugin/smoke/PowerShellIntegrationCheck.ps1 new file mode 100644 index 0000000..0b91f6c --- /dev/null +++ b/jetbrains_plugin/smoke/PowerShellIntegrationCheck.ps1 @@ -0,0 +1,47 @@ +$ErrorActionPreference = 'Stop' +$profilePath = Join-Path $PSScriptRoot '../src/main/resources/run-vs-agent-shell-integrations/vscode-powershell/profile.ps1' +$tokens = $null +$parseErrors = $null +[System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) | Out-Null +if ($parseErrors.Count) { throw ($parseErrors.Message -join '; ') } +if ([IO.File]::ReadAllText($profilePath) -match '[^\x00-\x7F]') { throw 'Profile must remain ASCII for Windows PowerShell 5.1.' } + +# Mock only console input/history; load the real profile in a child script scope. +Import-Module PSReadLine +$global:testHistoryId = 1 +function global:Get-History { [pscustomobject]@{ Id = $global:testHistoryId } } +function global:PSConsoleHostReadLine { 'Write-Output "hello;world"' } +$env:VSCODE_NONCE = 'test-nonce' +$originalConsole = [Console]::Out +$capture = New-Object IO.StringWriter +try { + [Console]::SetOut($capture) + & $profilePath + $unicodeValue = 'caf' + [char]0xe9 + ' ' + [char]0x65e5 + [char]0x672c + [char]0x8a9e + ' ' + [char]::ConvertFromUtf32(0x1f680) + $escapedUnicode = __VSCode-Escape-Value $unicodeValue + if ($escapedUnicode -match '[^\x00-\x7F]') { throw 'Unicode marker is not ASCII-safe.' } + if ($escapedUnicode -ne 'caf\xc3\xa9 \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e \xf0\x9f\x9a\x80') { throw 'Incorrect UTF-8 Unicode escapes.' } + $initialPrompt = prompt + $command = PSConsoleHostReadLine + $global:testHistoryId = 2 + $finishedPrompt = prompt + $null = PSConsoleHostReadLine + $global:testHistoryId = 3 + cmd.exe /c exit 7 + $failedPrompt = prompt + $null = PSConsoleHostReadLine + $global:testHistoryId = 4 + Write-Output 'alive' | Out-Null + $recoveredPrompt = prompt +} finally { [Console]::SetOut($originalConsole) } +$esc = [char]27 +$bell = [char]7 +$markers = $capture.ToString() +if ($command -ne 'Write-Output "hello;world"') { throw 'ReadLine did not preserve the command.' } +if (-not $markers.Contains("${esc}]633;E;Write-Output `"hello\x3bworld`";test-nonce${bell}")) { throw 'Missing command/nonce marker after script scope exited.' } +if (-not $markers.Contains("${esc}]633;C${bell}")) { throw 'Missing execution start marker.' } +if (-not $initialPrompt.Contains("${esc}]633;P;Cwd=")) { throw 'Missing working directory marker.' } +if (-not $finishedPrompt.Contains("${esc}]633;D;0${bell}")) { throw 'Missing successful completion marker.' } +if (-not $failedPrompt.Contains("${esc}]633;D;7${bell}")) { throw 'Native exit code 7 was not preserved.' } +if (-not $recoveredPrompt.Contains("${esc}]633;D;0${bell}")) { throw 'Stale native exit code reused for successful command.' } +'PASS: Windows PowerShell parsing, script-scope lifetime, command escaping, nonce, start, cwd and completion markers.' diff --git a/jetbrains_plugin/smoke/Smoke263DispatchCheck.java b/jetbrains_plugin/smoke/Smoke263DispatchCheck.java index 1a59340..e03ff41 100644 --- a/jetbrains_plugin/smoke/Smoke263DispatchCheck.java +++ b/jetbrains_plugin/smoke/Smoke263DispatchCheck.java @@ -3,15 +3,18 @@ import java.lang.reflect.Proxy; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Enumeration; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.jar.JarEntry; import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; /** * Headless proof for the 2026.3 terminal compatibility, run by the @@ -29,7 +32,8 @@ * valid override; it only cannot itself be overridden, which nothing * needs to do, *
  • no packaged class references LocalTerminalDirectRunner#createProcess, - * whose 2023.3 descriptor does not resolve on 2026.3,
  • + * whose 2023.3 descriptor does not resolve on 2026.3, and none + * references the internal LocalPtyOptions API, *
  • RawOutputTtyConnector decodes and forwards output when linked against * this IDE build's jediterm and pty4j binaries.
  • * @@ -44,8 +48,16 @@ public class Smoke263DispatchCheck { private static final String RUNNER_CLASS = "org/jetbrains/plugins/terminal/LocalTerminalDirectRunner"; private static final String DISPATCH_DESCRIPTOR = "(Lorg/jetbrains/plugins/terminal/ShellStartupOptions;)Lcom/jediterm/terminal/TtyConnector;"; - private static final String BANNED_REF_PREFIX = + private static final String REMOVED_CREATE_PROCESS_PREFIX = "org/jetbrains/plugins/terminal/LocalTerminalDirectRunner#createProcess#"; + + // LocalPtyOptions is platform internal API since 2024.2 and so is its + // Companion.shouldUseWinConPty, which no packaged class may call. The + // prefix list matches member references; descriptors alone never match. + private static final List BANNED_REF_PREFIXES = List.of( + REMOVED_CREATE_PROCESS_PREFIX, + "com/intellij/execution/process/LocalPtyOptions#", + "com/intellij/execution/process/LocalPtyOptions$Companion#"); private static final String MULTIBYTE = "naive β€” δ½ ε₯½, τξ΢, πŸš€ smoke"; private static final int ACC_PUBLIC = 0x0001; @@ -55,9 +67,15 @@ public class Smoke263DispatchCheck { private static final int ACC_INTERFACE = 0x0200; public static void main(String[] args) throws Exception { + if (args.length == 1 && "--self-test".equals(args[0])) { + selfTestBannedRefScanner(); + System.out.println("[smoke-263] self-test OK: scanner rejects the banned LocalPtyOptions references and passes clean jars"); + return; + } if (args.length == 2 && "--scan-jar".equals(args[0])) { checkNoBannedRefs(Paths.get(args[1])); - System.out.println("[smoke-263] scan OK: no packaged reference to " + BANNED_REF_PREFIX + "*"); + System.out.println("[smoke-263] scan OK: no packaged reference to " + + REMOVED_CREATE_PROCESS_PREFIX + "* or LocalPtyOptions*"); return; } Path pluginJar = Paths.get(args[0]); @@ -187,21 +205,162 @@ private static void checkNoBannedRefs(Path pluginJar) throws Exception { } ClassFile parsed = ClassFile.parse(jar.getInputStream(entry).readAllBytes()); for (String ref : parsed.memberRefs) { - if (ref.startsWith(BANNED_REF_PREFIX)) { - violations.add(entry.getName() + ": " + ref); + for (String prefix : BANNED_REF_PREFIXES) { + if (ref.startsWith(prefix)) { + violations.add(entry.getName() + ": " + ref); + } } } } } if (!violations.isEmpty()) { throw new AssertionError( - "Packaged bytecode references LocalTerminalDirectRunner.createProcess. IntelliJ " - + "2026.3 changed that method to return java.lang.Process, so a 2023.3 call " - + "site does not resolve:\n" + String.join("\n", violations) + "Packaged bytecode references banned platform API. LocalTerminalDirectRunner" + + ".createProcess does not resolve on IntelliJ 2026.3 (it returns " + + "java.lang.Process there), and LocalPtyOptions is internal API:\n" + + String.join("\n", violations) + "\nStart the process through TerminalInstance.startTerminalProcess instead."); } } + /** + * Synthetic fixture for the banned-reference scanner. The scanner parses + * constant pools, so the fixture builds minimal class files in a temporary + * directory; nothing is packaged with the plugin. The banned jar carries + * LocalPtyOptions and LocalPtyOptions$Companion member references and must + * be rejected; the clean jar carries public API references and a similarly + * named class and must pass. + */ + private static void selfTestBannedRefScanner() throws Exception { + Path temp = Files.createTempDirectory("smoke263-selftest"); + try { + Path banned = temp.resolve("banned.jar"); + writeJar(banned, Map.of( + "selftest/BannedRefs.class", syntheticClass("selftest/BannedRefs", new String[][]{ + {"com/intellij/execution/process/LocalPtyOptions", "shouldUseWinConPty", "()Z"}, + {"com/intellij/execution/process/LocalPtyOptions$Companion", "shouldUseWinConPty", "()Z"}, + }))); + AssertionError failure = null; + try { + checkNoBannedRefs(banned); + } catch (AssertionError caught) { + failure = caught; + } + if (failure == null) { + throw new AssertionError("self-test: scanner missed the LocalPtyOptions reference"); + } + if (!failure.getMessage().contains("LocalPtyOptions#") + || !failure.getMessage().contains("LocalPtyOptions$Companion")) { + throw new AssertionError( + "self-test: scanner report does not name both banned owners:\n" + failure.getMessage(), + failure); + } + + Path clean = temp.resolve("clean.jar"); + writeJar(clean, Map.of( + "selftest/CleanRefs.class", syntheticClass("selftest/CleanRefs", new String[][]{ + {"com/pty4j/PtyProcess", "getWinSize", "()Lcom/pty4j/WinSize;"}, + {"com/intellij/execution/process/LocalPtyOptionsTest", "notInternal", "()V"}, + }))); + checkNoBannedRefs(clean); + } finally { + deleteRecursively(temp); + } + } + + /** Builds a minimal class file whose constant pool carries the given method references. */ + private static byte[] syntheticClass(String selfName, String[][] methodRefs) { + PoolBuilder pool = new PoolBuilder(); + int selfClass = pool.clazz(pool.utf8(selfName)); + int superClass = pool.clazz(pool.utf8("java/lang/Object")); + for (String[] ref : methodRefs) { + int owner = pool.clazz(pool.utf8(ref[0])); + int nameAndType = pool.nameAndType(pool.utf8(ref[1]), pool.utf8(ref[2])); + pool.methodref(owner, nameAndType); + } + + java.io.ByteArrayOutputStream body = new java.io.ByteArrayOutputStream(); + body.write(new byte[]{(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE}, 0, 4); + writeU2(body, 0); + writeU2(body, 52); + writeU2(body, pool.slotCount); + body.write(pool.bytes, 0, pool.length); + writeU2(body, ACC_PUBLIC | ACC_SUPER); + writeU2(body, selfClass); + writeU2(body, superClass); + writeU2(body, 0); + writeU2(body, 0); + writeU2(body, 0); + writeU2(body, 0); + return body.toByteArray(); + } + + /** Grows a constant pool with the entry types the banned-reference parser consumes. */ + static final class PoolBuilder { + final byte[] bytes = new byte[65536]; + int length; + int slotCount = 1; + + int utf8(String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + bytes[length++] = 1; + writeU2(encoded.length); + System.arraycopy(encoded, 0, bytes, length, encoded.length); + length += encoded.length; + return slotCount++; + } + + int clazz(int nameSlot) { + bytes[length++] = 7; + writeU2(nameSlot); + return slotCount++; + } + + int nameAndType(int nameSlot, int descriptorSlot) { + bytes[length++] = 12; + writeU2(nameSlot); + writeU2(descriptorSlot); + return slotCount++; + } + + int methodref(int classSlot, int nameAndTypeSlot) { + bytes[length++] = 10; + writeU2(classSlot); + writeU2(nameAndTypeSlot); + return slotCount++; + } + + private void writeU2(int value) { + bytes[length++] = (byte) (value >>> 8); + bytes[length++] = (byte) value; + } + } + + private static void writeJar(Path path, Map entries) throws Exception { + try (JarOutputStream jar = new JarOutputStream(Files.newOutputStream(path))) { + for (Map.Entry entry : entries.entrySet()) { + jar.putNextEntry(new JarEntry(entry.getKey())); + jar.write(entry.getValue()); + } + } + } + + private static void writeU2(java.io.ByteArrayOutputStream out, int value) { + out.write((value >>> 8) & 0xFF); + out.write(value & 0xFF); + } + + private static void deleteRecursively(Path root) throws Exception { + if (Files.isDirectory(root)) { + try (DirectoryStream children = Files.newDirectoryStream(root)) { + for (Path child : children) { + deleteRecursively(child); + } + } + } + Files.deleteIfExists(root); + } + private static void checkConnectorForwards() throws Exception { Class connectorClass = Class.forName("org.zoocode.jetbrains.terminal.RawOutputTtyConnector"); Class callbackClass = Class.forName("org.zoocode.jetbrains.terminal.TerminalRawDataCallback"); diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/core/ExtensionHostManager.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/core/ExtensionHostManager.kt index 45a0163..8c5aacb 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/core/ExtensionHostManager.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/core/ExtensionHostManager.kt @@ -7,6 +7,7 @@ package org.zoocode.jetbrains.core import com.google.gson.Gson import com.intellij.openapi.Disposable +import com.intellij.openapi.application.PathManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.application.ApplicationInfo @@ -25,6 +26,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import java.net.Socket import java.nio.channels.SocketChannel +import java.nio.file.Files import java.nio.file.Paths import org.zoocode.jetbrains.extensions.core.ExtensionManager as GlobalExtensionManager import org.zoocode.jetbrains.extensions.config.ExtensionProvider @@ -309,7 +311,7 @@ class ExtensionHostManager : Disposable { ), "logLevel" to 0, // Info level "loggers" to emptyList(), - "logsLocation" to uriFromPath(Paths.get(pluginDir, "logs").toString()), + "logsLocation" to uriFromPath(getExtensionHostLogsDir()), "autoStart" to true, "consoleForward" to mapOf( "includeStack" to false, @@ -374,6 +376,21 @@ class ExtensionHostManager : Disposable { return PluginResourceUtil.getResourcePath(PluginConstants.PLUGIN_ID, "") ?: throw IllegalStateException("Unable to get plugin directory") } + + /** + * Keep extension-host logs outside the plugin installation directory so + * JetBrains can replace the plugin during update/restart even if Node still + * has exthost.log open for a short time. + */ + private fun getExtensionHostLogsDir(): String { + val logsDir = Paths.get(PathManager.getLogPath(), PluginConstants.PLUGIN_ID, "extension-host") + try { + Files.createDirectories(logsDir) + } catch (e: Exception) { + LOG.warn("Failed to create extension host logs directory: $logsDir", e) + } + return logsDir.toString() + } /** * Get extension path from configuration diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/core/NodeRuntimeManager.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/core/NodeRuntimeManager.kt index 1a00a85..e71e54e 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/core/NodeRuntimeManager.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/core/NodeRuntimeManager.kt @@ -5,10 +5,10 @@ package org.zoocode.jetbrains.core import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task import com.intellij.openapi.application.PathManager import com.intellij.openapi.util.SystemInfo import okhttp3.OkHttpClient @@ -160,27 +160,29 @@ object NodeRuntimeManager { } /** - * Run the download under an IDE background progress indicator. + * Run the download under a modal progress indicator. * Blocks the calling thread (must not be the EDT) until the download finishes. */ private fun downloadWithProgress(dist: NodeDistribution): String? { var installedPath: String? = null var canceled = false - val task = object : Task.Backgroundable(null, "Downloading Node.js runtime", true) { - override fun run(indicator: ProgressIndicator) { - try { - installedPath = doDownload(dist, indicator) - } catch (e: ProcessCanceledException) { - canceled = true - LOG.info("Node.js runtime download canceled by user") - } catch (e: Exception) { - LOG.warn("Failed to download Node.js runtime from ${dist.archiveUrl}", e) - } + // ProgressManager.run(Task) queues a Backgroundable and returns before + // it finishes, so every first launch saw null and the extension host + // did not start until the next IDE session. The synchronous variant + // blocks the caller while still showing progress; the calling thread + // is never the EDT. + ProgressManager.getInstance().runProcessWithProgressSynchronously({ + try { + val indicator = ProgressManager.getInstance().progressIndicator + installedPath = doDownload(dist, indicator ?: EmptyProgressIndicator()) + } catch (e: ProcessCanceledException) { + canceled = true + LOG.info("Node.js runtime download canceled by user") + } catch (e: Exception) { + LOG.warn("Failed to download Node.js runtime from ${dist.archiveUrl}", e) } - } - - ProgressManager.getInstance().run(task) + }, "Downloading Node.js runtime", true, null) if (installedPath != null) { clearDownloadFailure(dist.version) diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/extensions/ui/actions/ExtensionStatusChecker.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/extensions/ui/actions/ExtensionStatusChecker.kt index 234ccc3..32473b2 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/extensions/ui/actions/ExtensionStatusChecker.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/extensions/ui/actions/ExtensionStatusChecker.kt @@ -17,9 +17,9 @@ import javax.swing.* import org.zoocode.jetbrains.extensions.core.ExtensionManager import org.zoocode.jetbrains.core.PluginContext import org.zoocode.jetbrains.core.ServiceProxyRegistry +import org.zoocode.jetbrains.util.JcefSupport import org.zoocode.jetbrains.util.ProxyConfigUtil import org.zoocode.jetbrains.webview.WebViewManager -import com.intellij.ui.jcef.JBCefApp import com.intellij.openapi.application.ApplicationInfo import org.zoocode.jetbrains.util.PluginInfo @@ -27,24 +27,24 @@ import org.zoocode.jetbrains.util.PluginInfo * Action to check extension status and diagnose issues */ class ExtensionStatusChecker : AnAction("Check Extension Status") { - + private val logger = Logger.getInstance(ExtensionStatusChecker::class.java) - + override fun actionPerformed(e: AnActionEvent) { val project = e.getData(CommonDataKeys.PROJECT) ?: return - + val status = checkExtensionStatus(project) showStatusDialog(status) } - + private fun checkExtensionStatus(project: Project): String { val sb = StringBuilder() sb.appendLine("πŸ” Extension Status Check") sb.appendLine("=".repeat(50)) - + // Add System Information addSystemInformation(sb) - + // Check Extension Manager try { val extensionManager = ExtensionManager.getInstance(project) @@ -54,17 +54,17 @@ class ExtensionStatusChecker : AnAction("Check Extension Status") { } catch (e: Exception) { sb.appendLine("❌ Extension Manager Error: ${e.message}") } - + // Check Plugin Context try { val pluginContext = project.getService(PluginContext::class.java) if (pluginContext != null) { sb.appendLine("βœ… PluginContext: Available") - + val rpcProtocol = pluginContext.getRPCProtocol() if (rpcProtocol != null) { sb.appendLine("βœ… RPC Protocol: Available") - + val commandsProxy = rpcProtocol.getProxy(ServiceProxyRegistry.ExtHostContext.ExtHostCommands) if (commandsProxy != null) { sb.appendLine("βœ… ExtHostCommands Proxy: Available") @@ -80,7 +80,7 @@ class ExtensionStatusChecker : AnAction("Check Extension Status") { } catch (e: Exception) { sb.appendLine("❌ Plugin Context Error: ${e.message}") } - + // Check available extensions try { val extensionManager = ExtensionManager.getInstance(project) @@ -92,7 +92,7 @@ class ExtensionStatusChecker : AnAction("Check Extension Status") { } catch (e: Exception) { sb.appendLine("❌ Error getting available extensions: ${e.message}") } - + // Check WebView status try { val webViewManager = project.getService(WebViewManager::class.java) @@ -110,12 +110,12 @@ class ExtensionStatusChecker : AnAction("Check Extension Status") { } catch (e: Exception) { sb.appendLine("\n❌ WebView Status Error: ${e.message}") } - + // Check Proxy status try { val proxyConfig = ProxyConfigUtil.getProxyConfig() sb.appendLine("\n🌐 Proxy Status:") - + val sourceDescription = when (proxyConfig.source) { "ide-pac" -> "IDE Settings (PAC)" "ide-http" -> "IDE Settings (HTTP Proxy)" @@ -127,14 +127,14 @@ class ExtensionStatusChecker : AnAction("Check Extension Status") { else -> proxyConfig.source } sb.appendLine(" Source: $sourceDescription") - + if (proxyConfig.hasProxy) { if (!proxyConfig.pacUrl.isNullOrEmpty()) { sb.appendLine(" PAC URL: ${proxyConfig.pacUrl}") } else if (!proxyConfig.proxyUrl.isNullOrEmpty()) { sb.appendLine(" Proxy URL: ${proxyConfig.proxyUrl}") } - + if (!proxyConfig.proxyExceptions.isNullOrEmpty()) { sb.appendLine(" No Proxy For: ${proxyConfig.proxyExceptions}") } @@ -144,10 +144,10 @@ class ExtensionStatusChecker : AnAction("Check Extension Status") { } catch (e: Exception) { sb.appendLine("\n❌ Proxy Status Error: ${e.message}") } - + return sb.toString() } - + /** * Add system information to the status report */ @@ -158,62 +158,62 @@ class ExtensionStatusChecker : AnAction("Check Extension Status") { val osName = System.getProperty("os.name") val osVersion = System.getProperty("os.version") val osArch = System.getProperty("os.arch") - val jcefSupported = JBCefApp.isSupported() - + val jcefRenderable = JcefSupport.current().isRenderable + // Check for Linux ARM system val isLinuxArm = osName.lowercase().contains("linux") && (osArch.lowercase().contains("aarch64") || osArch.lowercase().contains("arm")) - + sb.appendLine("\nπŸ“Š System Information:") sb.appendLine(" πŸ’» CPU Architecture: $osArch") sb.appendLine(" πŸ–₯️ Operating System: $osName $osVersion") sb.appendLine(" πŸ”§ IDE Version: ${appInfo.fullApplicationName} (build ${appInfo.build})") sb.appendLine(" πŸ“¦ Plugin Version: $pluginVersion") - sb.appendLine(" 🌐 JCEF Support: ${if (jcefSupported) "βœ… Yes" else "❌ No"}") - + sb.appendLine(" 🌐 JCEF Support: ${if (jcefRenderable) "βœ… Yes" else "❌ No"}") + // Add warnings for unsupported configurations if (isLinuxArm) { sb.appendLine(" ⚠️ Warning: Linux ARM systems are currently not supported") } - - if (!jcefSupported) { + + if (!jcefRenderable) { sb.appendLine(" ❌ Warning: JCEF not supported - WebView functionality may not work") } - + } catch (e: Exception) { sb.appendLine("\n❌ System Information Error: ${e.message}") } } - + private fun showStatusDialog(status: String) { val dialog = ExtensionStatusDialog(status) dialog.show() } - + private class ExtensionStatusDialog(private val statusText: String) : DialogWrapper(true) { - + init { title = "Extension Status" init() } - + override fun createCenterPanel(): JComponent { val panel = JPanel() panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS) - + val textArea = JTextArea(statusText) textArea.isEditable = false textArea.font = JLabel().font textArea.background = JLabel().background - + val scrollPane = JScrollPane(textArea) scrollPane.preferredSize = java.awt.Dimension(600, 400) scrollPane.verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED scrollPane.horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED - + panel.add(scrollPane) return panel } - + override fun createActions(): Array { val copyAction = object : AbstractAction("Copy to Clipboard") { override fun actionPerformed(e: java.awt.event.ActionEvent?) { @@ -222,7 +222,7 @@ class ExtensionStatusChecker : AnAction("Check Extension Status") { Messages.showInfoMessage("Status information copied to clipboard!", "Copied") } } - + return arrayOf(copyAction, okAction) } } diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/plugin/WecoderPlugin.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/plugin/WecoderPlugin.kt index 6bd802c..a4357cf 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/plugin/WecoderPlugin.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/plugin/WecoderPlugin.kt @@ -23,12 +23,12 @@ import kotlinx.coroutines.* import java.util.Properties import java.io.InputStream import com.intellij.openapi.util.SystemInfo -import com.intellij.ui.jcef.JBCefApp import com.intellij.openapi.application.ApplicationInfo import org.zoocode.jetbrains.core.* import org.zoocode.jetbrains.extensions.core.ExtensionConfigurationManager import org.zoocode.jetbrains.extensions.core.ExtensionManager import org.zoocode.jetbrains.util.ExtensionUtils +import org.zoocode.jetbrains.util.JcefSupport import org.zoocode.jetbrains.util.PluginConstants import org.zoocode.jetbrains.util.PluginInfo import org.zoocode.jetbrains.util.PluginResourceUtil @@ -71,7 +71,7 @@ class WecoderPlugin : StartupActivity.DumbAware { "OS: $osName $osVersion ($osArch), " + "IDE: ${appInfo.fullApplicationName} (build ${appInfo.build}), " + "Plugin version: $pluginVersion, " + - "JCEF supported: ${JBCefApp.isSupported()}" + "JCEF: ${JcefSupport.current()}" ) try { @@ -525,13 +525,14 @@ class WecoderPluginService(private var currentProject: Project) : Disposable { LOG.info("Disposing WecoderPluginService") - currentProject.getService(WebViewManager::class.java)?.dispose() - - // Cancel all coroutines - coroutineScope.cancel() - - // Clean up resources - cleanup() + try { + currentProject.getService(WebViewManager::class.java)?.dispose() + } catch (e: Exception) { + LOG.warn("Error disposing web view during shutdown", e) + } finally { + coroutineScope.cancel() + cleanup() + } LOG.info("WecoderPluginService disposed") } diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/ShellIntegrationOutputState.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/ShellIntegrationOutputState.kt index 648fb3b..0ded8c0 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/ShellIntegrationOutputState.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/ShellIntegrationOutputState.kt @@ -14,6 +14,7 @@ import java.util.concurrent.atomic.AtomicLong * Shell integration event types */ sealed class ShellEvent { + object ShellIntegrationReady : ShellEvent() data class ShellExecutionStart(val commandLine: String, val cwd: String) : ShellEvent() data class ShellExecutionEnd(val commandLine: String, val exitCode: Int?) : ShellEvent() data class ShellExecutionData(val data: String) : ShellEvent() @@ -24,6 +25,7 @@ sealed class ShellEvent { * Shell integration event listener */ interface ShellEventListener { + fun onShellIntegrationReady() = Unit fun onShellExecutionStart(commandLine: String, cwd: String) fun onShellExecutionEnd(commandLine: String, exitCode: Int?) fun onShellExecutionData(data: String) @@ -74,6 +76,8 @@ class ShellIntegrationOutputState { // Coroutine scope private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var pendingRawOutput = "" + private var integrationReady = false /** * Add event listener @@ -101,6 +105,7 @@ class ShellIntegrationOutputState { listeners.forEach { listener -> try { when (event) { + ShellEvent.ShellIntegrationReady -> listener.onShellIntegrationReady() is ShellEvent.ShellExecutionStart -> listener.onShellExecutionStart(event.commandLine, event.cwd) is ShellEvent.ShellExecutionEnd -> @@ -192,7 +197,10 @@ class ShellIntegrationOutputState { * Process raw output data * Parse Shell Integration markers and extract clean content */ - fun appendRawOutput(output: String) { + @Synchronized + fun appendRawOutput(chunk: String) { + val output = pendingRawOutput + chunk + pendingRawOutput = "" logger.debug("πŸ“₯ Processing raw output: ${output.length} chars, isCommandRunning=$isCommandRunning") logger.debug("πŸ“₯ Raw output content: '${output.replace("\u001b", "\\u001b").replace("\u0007", "\\u0007")}'") @@ -205,7 +213,13 @@ class ShellIntegrationOutputState { if (markerIndex == -1) { // No marker found - val remainingContent = output.substring(currentIndex) + val remaining = output.substring(currentIndex) + val prefix = "\u001b]633;" + val partialLength = (1 until prefix.length).lastOrNull { + remaining.endsWith(prefix.take(it)) + } ?: 0 + pendingRawOutput = remaining.takeLast(partialLength) + val remainingContent = remaining.dropLast(partialLength) logger.debug("πŸ“€ No Shell Integration marker found, remaining content: '${remainingContent}', isCommandRunning=$isCommandRunning") if (!hasShellIntegrationMarkers && remainingContent.isNotEmpty()) { @@ -236,9 +250,7 @@ class ShellIntegrationOutputState { // Parse marker val typeStart = markerIndex + 6 // "\u001b]633;".length if (typeStart >= output.length) { - if (isCommandRunning && currentIndex < output.length) { - appendOutput(output.substring(currentIndex)) - } + pendingRawOutput = output.substring(markerIndex) break } @@ -248,9 +260,8 @@ class ShellIntegrationOutputState { // Find marker end: \u0007 val paramEnd = output.indexOf('\u0007', paramStart) if (paramEnd == -1) { - logger.debug("⚠️ Marker end not found, skip") - currentIndex = typeStart - continue + pendingRawOutput = output.substring(markerIndex) + break } // Extract parameters @@ -273,7 +284,7 @@ class ShellIntegrationOutputState { MarkerType.COMMAND_LINE -> { logger.info("🎯 Shell Integration - Detected command line marker") if (components.isNotEmpty() && components[0].isNotEmpty()) { - currentCommand = components[0] + currentCommand = decodeMarkerValue(components[0]) currentNonce = if (components.size >= 2) components[1] else "" logger.info("🎯 Shell Integration - Command line: '$currentCommand'") } @@ -310,7 +321,7 @@ class ShellIntegrationOutputState { if (components.isNotEmpty()) { val property = components[0] if (property.startsWith("Cwd=")) { - val cwdValue = property.substring(4) // "Cwd=".length + val cwdValue = decodeMarkerValue(property.substring(4)) if (cwdValue != currentDirectory) { currentDirectory = cwdValue logger.info("πŸ“ Shell Integration - Directory changed: '$cwdValue'") @@ -325,6 +336,10 @@ class ShellIntegrationOutputState { } MarkerType.COMMAND_START -> { + if (!integrationReady) { + integrationReady = true + notifyListeners(ShellEvent.ShellIntegrationReady) + } logger.debug("🎯 Shell Integration - Command input start") } @@ -336,6 +351,17 @@ class ShellIntegrationOutputState { currentIndex = paramEnd + 1 } } + + internal fun decodeMarkerValue(value: String): String { + return Regex("(?:\\\\x[0-9a-fA-F]{2})+|\\\\\\\\").replace(value) { match -> + if (match.value == "\\\\") { + "\\" + } else { + val bytes = match.value.chunked(4).map { it.substring(2).toInt(16).toByte() }.toByteArray() + bytes.toString(Charsets.UTF_8) + } + } + } /** * Get clean output with Shell Integration markers removed @@ -391,4 +417,4 @@ class ShellIntegrationOutputState { } } } -} \ No newline at end of file +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalInstance.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalInstance.kt index 8ff20ad..30afe79 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalInstance.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalInstance.kt @@ -5,7 +5,6 @@ package org.zoocode.jetbrains.terminal import com.intellij.execution.ExecutionException -import com.intellij.execution.process.LocalPtyOptions import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger @@ -75,7 +74,11 @@ class TerminalInstance( private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Shell integration manager - private val terminalShellIntegration = TerminalShellIntegration(extHostTerminalId, numericId, rpcProtocol) + private val terminalShellIntegration = TerminalShellIntegration(extHostTerminalId, numericId, rpcProtocol) { + ApplicationManager.getApplication().invokeLater { + if (state.canOperate()) notifyShellIntegrationChange() + } + } // Event callback manager private val callbackManager = TerminalCallbackManager() @@ -131,8 +134,8 @@ class TerminalInstance( */ private fun performInitialization() { try { - createTerminalWidget() setupShellIntegration() + createTerminalWidget() finalizeInitialization() } catch (e: Exception) { logger.error("❌ Failed to initialize terminal in EDT thread: $extHostTerminalId", e) @@ -158,7 +161,6 @@ class TerminalInstance( addToTerminalToolWindow() notifyTerminalOpened() - notifyShellIntegrationChange() handleInitialText() } @@ -279,12 +281,13 @@ class TerminalInstance( logger.info("πŸ”§ Starting PTY process: command=$command, cwd=$workingDirectory") try { - val builder = PtyProcessBuilder(command.toTypedArray()) - .setEnvironment(options.envVariables) - .setDirectory(workingDirectory) - .setInitialColumns(initialTermSize?.columns) - .setInitialRows(initialTermSize?.rows) - .setUseWinConPty(LocalPtyOptions.shouldUseWinConPty()) + val builder = TerminalPtyBuilder.configure( + command.toTypedArray(), + options.envVariables, + workingDirectory, + initialTermSize?.columns, + initialTermSize?.rows + ) val process = builder.start() logger.info("βœ… PTY process created: ${process.javaClass.name}") return process @@ -482,12 +485,19 @@ class TerminalInstance( * Send text to terminal */ fun sendText(text: String, shouldExecute: Boolean = false) { - if (!state.canOperate()) { - logger.warn("Terminal not initialized or disposed, cannot send text: $extHostTerminalId") - return + if (shouldExecute) { + terminalShellIntegration.whenReady { sendReadyText(text, true) } + } else { + sendReadyText(text, false) } + } + private fun sendReadyText(text: String, shouldExecute: Boolean) { ApplicationManager.getApplication().invokeLater { + if (!state.canOperate()) { + logger.warn("Terminal not initialized or disposed, cannot send text: $extHostTerminalId") + return@invokeLater + } try { val shell = shellWidget ?: return@invokeLater @@ -758,3 +768,34 @@ private class TerminalCallbackManager { terminalCloseCallbacks.clear() } } + +/** + * Builder policy for this plugin's PTY processes. + * + * ConPTY is requested explicitly through the public pty4j setter. This is a + * plugin decision: the platform accessor LocalPtyOptions is internal API + * since 2024.2, so one binary cannot read the user registry key + * terminal.use.conpty.on.windows the way the 2023.3 platform terminal did. + * + * Verified against the pty4j builds inside 2023.3 (lib/util.jar) and + * 2026.3 (intellij.libraries.pty4j.jar): PtyProcessBuilder.start reads the + * flag only on Windows, requests WinConPtyProcess for a non-console process, + * logs "Cannot create ConPTY process, fallback to winpty" and returns + * WinPtyProcess when the ConPTY native library fails to load + * (UnsatisfiedLinkError). Linux and macOS never read the flag. + */ +internal object TerminalPtyBuilder { + fun configure( + command: Array, + environment: Map, + workingDirectory: String, + initialColumns: Int?, + initialRows: Int? + ): PtyProcessBuilder = + PtyProcessBuilder(command) + .setEnvironment(environment) + .setDirectory(workingDirectory) + .setInitialColumns(initialColumns) + .setInitialRows(initialRows) + .setUseWinConPty(true) +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalShellIntegration.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalShellIntegration.kt index eb586fe..1fd6f42 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalShellIntegration.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalShellIntegration.kt @@ -20,7 +20,8 @@ import org.zoocode.jetbrains.util.URI class TerminalShellIntegration( private val extHostTerminalId: String, private val numericId: Int, - private val rpcProtocol: IRPCProtocol + private val rpcProtocol: IRPCProtocol, + private val onReady: () -> Unit = {} ) { companion object { @@ -39,6 +40,21 @@ class TerminalShellIntegration( private val logger = Logger.getInstance(TerminalShellIntegration::class.java) private var shellIntegrationState: ShellIntegrationOutputState? = null private var shellEventListener: ShellEventListener? = null + private val readinessLock = Any() + private var ready = false + private var disposed = false + private val pendingCommands = mutableListOf<() -> Unit>() + + fun whenReady(command: () -> Unit) { + synchronized(readinessLock) { + if (disposed) return + if (!ready) { + pendingCommands.add(command) + return + } + } + command() + } /** * Lazy delegate for getting ExtHost terminal shell integration proxy @@ -68,6 +84,10 @@ class TerminalShellIntegration( * Dispose shell integration and release related resources */ fun dispose() { + synchronized(readinessLock) { + disposed = true + pendingCommands.clear() + } logger.info("$LOG_PREFIX_DISPOSE Disposing shell integration: $extHostTerminalId") runCatching { @@ -127,6 +147,15 @@ class TerminalShellIntegration( * Handles various shell command execution events */ private inner class TerminalShellEventListener : ShellEventListener { + override fun onShellIntegrationReady() { + val commands = synchronized(readinessLock) { + if (disposed || ready) return + ready = true + pendingCommands.toList().also { pendingCommands.clear() } + } + onReady() + commands.forEach { it() } + } override fun onShellExecutionStart(commandLine: String, cwd: String) { logger.info("$LOG_PREFIX_START Command execution started: '$commandLine' in directory '$cwd' (terminal: $extHostTerminalId)") @@ -179,4 +208,4 @@ class TerminalShellIntegration( } } } -} \ No newline at end of file +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/ui/ZooCodeJetBrainsToolWindowFactory.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/ui/ZooCodeJetBrainsToolWindowFactory.kt index 90c6f44..7a5ac63 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/ui/ZooCodeJetBrainsToolWindowFactory.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/ui/ZooCodeJetBrainsToolWindowFactory.kt @@ -12,7 +12,6 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentFactory -import com.intellij.ui.jcef.JBCefApp import com.intellij.openapi.application.ApplicationInfo import com.intellij.ide.BrowserUtil import org.zoocode.jetbrains.actions.OpenDevToolsAction @@ -23,6 +22,7 @@ import org.zoocode.jetbrains.webview.DragDropHandler import org.zoocode.jetbrains.webview.WebViewCreationCallback import org.zoocode.jetbrains.webview.WebViewInstance import org.zoocode.jetbrains.webview.WebViewManager +import org.zoocode.jetbrains.util.JcefSupport import org.zoocode.jetbrains.util.PluginConstants import org.zoocode.jetbrains.util.PluginInfo as PluginRuntimeInfo import org.zoocode.jetbrains.extensions.core.ExtensionConfigurationManager @@ -87,7 +87,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { // Get ExtensionConfigurationManager instance private val configManager = ExtensionConfigurationManager.getInstance(project) - + // Get ExtensionManager instance private val extensionManager = ExtensionManager.getInstance(project) @@ -139,7 +139,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { val osName = System.getProperty("os.name") val osVersion = System.getProperty("os.version") val osArch = System.getProperty("os.arch") - val jcefSupported = JBCefApp.isSupported() + val jcefRenderable = JcefSupport.current().isRenderable val javaVersion = System.getProperty("java.version") // Check for Linux ARM system @@ -158,7 +158,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { append("
  • IDE: ${appInfo.fullApplicationName}
  • ") append("
  • Plugin: v$pluginVersion
  • ") append("
  • Java: $javaVersion
  • ") - append("
  • JCEF: ${if (jcefSupported) "Supported" else "Not Supported"}
  • ") + append("
  • JCEF: ${if (jcefRenderable) "Supported" else "Not Supported"}
  • ") append("") append("") @@ -170,7 +170,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { append("") } - if (!jcefSupported) { + if (!jcefRenderable) { append("
    ") append("

    ⚠️ JCEF Not Supported

    ") append("

    Please use a JCEF-enabled runtime.

    ") @@ -211,7 +211,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { val osVersion = System.getProperty("os.version") val osArch = System.getProperty("os.arch") val javaVersion = System.getProperty("java.version") - val jcefSupported = JBCefApp.isSupported() + val jcefRenderable = JcefSupport.current().isRenderable // Check for Linux ARM system val isLinuxArm = osName.lowercase().contains("linux") && (osArch.lowercase().contains("aarch64") || osArch.lowercase().contains("arm")) @@ -225,14 +225,14 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { append(" IDE: ${appInfo.fullApplicationName} (build ${appInfo.build})\n") append(" Plugin: v$pluginVersion\n") append(" Java: $javaVersion\n") - append(" JCEF Support: ${if (jcefSupported) "Yes" else "No"}\n") + append(" JCEF Support: ${if (jcefRenderable) "Yes" else "No"}\n") // Add warning messages if (isLinuxArm) { append("\nWarning: Linux ARM systems are currently not supported.\n") } - if (!jcefSupported) { + if (!jcefRenderable) { append("\nError: Your IDE runtime does not support JCEF. Please use a runtime with JCEF support.\n") } } @@ -274,7 +274,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { // Check configuration status and show appropriate content if (configManager.isConfigurationLoaded() && configManager.isConfigurationValid()) { - + val initPanel = JPanel(BorderLayout()).apply { border = BorderFactory.createEmptyBorder(20, 20, 20, 20) add(placeholderLabel, BorderLayout.CENTER) @@ -301,10 +301,10 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { add(buttonPanel, BorderLayout.SOUTH) } contentPanel.add(initPanel, BorderLayout.CENTER) - + // Don't auto-start here - WecoderPlugin will handle startup // The plugin will start automatically if configuration is valid - + // Start configuration monitoring startConfigurationMonitoring() @@ -528,15 +528,15 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { showDebugInfo() } } - + buttonPanel.add(debugButton, BorderLayout.WEST) - + // Add all components panel.add(titleLabel, BorderLayout.NORTH) panel.add(descLabel, BorderLayout.CENTER) panel.add(pluginListPanel, BorderLayout.CENTER) panel.add(buttonPanel, BorderLayout.SOUTH) - + return panel } @@ -556,7 +556,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { val extensionId = provider.getExtensionId() val isCurrent = provider.getExtensionId() == currentExtensionId val isAvailable = provider.isAvailable(project) - + PluginInfo( id = extensionId, displayName = provider.getDisplayName(), @@ -580,12 +580,12 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { */ private fun createPluginRow(pluginInfo: PluginInfo): JPanel { val rowPanel = JPanel(BorderLayout()) - + // Main content panel - use default IDE styling val contentPanel = JPanel(BorderLayout()).apply { // Use default panel background from IDE theme isOpaque = true - + // Simple border without custom colors val borderWidth = if (pluginInfo.isCurrent) 2 else 1 border = BorderFactory.createCompoundBorder( @@ -630,7 +630,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { font = JBFont.label() isEnabled = true isFocusPainted = false - + addActionListener { uploadVsixForPlugin(pluginInfo.id, pluginInfo.displayName) } @@ -671,7 +671,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { applyPluginSelection(pluginInfo.id) } } - + override fun mouseEntered(e: java.awt.event.MouseEvent) { contentPanel.cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) // Simple hover border effect using IDE theme colors @@ -681,7 +681,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { ) contentPanel.repaint() } - + override fun mouseExited(e: java.awt.event.MouseEvent) { contentPanel.cursor = java.awt.Cursor.getDefaultCursor() // Restore normal border @@ -730,7 +730,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { try { // Use VsixUploadDialog directly val success = VsixUploadDialog.show(project, pluginId, pluginName) - + if (success) { javax.swing.JOptionPane.showMessageDialog( contentPanel, @@ -749,7 +749,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { ) } } - + /** * Create configuration status panel */ @@ -757,33 +757,33 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { val panel = JPanel() panel.layout = BorderLayout() panel.border = javax.swing.BorderFactory.createEmptyBorder(20, 20, 20, 20) - + // Status label val statusLabel = JLabel().apply { font = font.deriveFont(14f) horizontalAlignment = javax.swing.SwingConstants.CENTER } - + // Update status updateConfigStatus(statusLabel) - + panel.add(statusLabel, BorderLayout.CENTER) return panel } - + /** * Update configuration status */ private fun updateConfigStatus(statusLabel: JLabel) { // Detect current theme for status colors val isDarkTheme = detectCurrentTheme() - + if (configManager.isConfigurationLoaded()) { if (configManager.isConfigurationValid()) { val extensionId = configManager.getCurrentExtensionId() // Check if plugin is actually running val isPluginRunning = isPluginActuallyRunning() - + if (isPluginRunning) { statusLabel.text = "βœ… Plugin Running - Current Plugin: $extensionId" statusLabel.foreground = getThemeAdaptiveColor(isDarkTheme, "success") @@ -814,22 +814,22 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { else -> javax.swing.UIManager.getColor("Label.foreground") ?: java.awt.Color(128, 128, 128) } } - + /** * Apply plugin selection and create configuration */ private fun applyPluginSelection(pluginId: String) { try { logger.info("Applying plugin selection: $pluginId") - + // Create configuration with selected plugin configManager.setCurrentExtensionId(pluginId) - + // Verify configuration was saved successfully if (configManager.isConfigurationValid()) { // Start the plugin directly instead of just saving configuration startPluginAfterSelection(pluginId) - + logger.info("Plugin selection applied successfully: $pluginId") } else { // Configuration is still invalid after setting @@ -841,7 +841,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { "Configuration Update Failed", javax.swing.JOptionPane.ERROR_MESSAGE ) - + logger.error("Configuration is still invalid after setting extension ID: $pluginId, error: $errorMsg") } } catch (e: Exception) { @@ -855,40 +855,40 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { ) } } - + /** * Start plugin after plugin selection */ private fun startPluginAfterSelection(pluginId: String) { try { logger.info("Starting plugin after selection: $pluginId") - + // Set plugin starting state isPluginStarting = true - + // Update status to show plugin is starting updateConfigStatus(configStatusPanel.getComponent(0) as JLabel) - + // Get extension manager and set the selected provider val extensionManager = ExtensionManager.getInstance(project) extensionManager.initialize(pluginId) - + // Initialize the current provider extensionManager.initializeCurrentProvider() - + // Start plugin service val pluginService = WecoderPlugin.getInstance(project) pluginService.initialize(project) - + // Initialize WebViewManager val webViewManager = project.getService(WebViewManager::class.java) if (webViewManager != null) { // Register to project Disposer com.intellij.openapi.util.Disposer.register(project, webViewManager) - + // Start configuration monitoring startConfigurationMonitoring() - + // Register project-level resource disposal com.intellij.openapi.util.Disposer.register(project, com.intellij.openapi.Disposable { logger.info("Disposing Zoo Code JetBrains plugin for project: ${project.name}") @@ -899,26 +899,26 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { isPluginRunning = false isPluginStarting = false }) - + logger.info("Plugin started successfully after selection: $pluginId") - + // Set plugin running state isPluginRunning = true isPluginStarting = false - + // Update UI to show plugin is running updateUIContent() } else { logger.error("WebViewManager not available") throw IllegalStateException("WebViewManager not available") } - + } catch (e: Exception) { logger.error("Failed to start plugin after selection", e) // Reset state on failure isPluginStarting = false isPluginRunning = false - + val message = "❌ Plugin Startup Failed\nError: ${e.message}\n\nPlease check plugin configuration or try restarting the IDE." javax.swing.JOptionPane.showMessageDialog( contentPanel, @@ -928,7 +928,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { ) } } - + /** * Update UI content based on configuration status */ @@ -938,20 +938,20 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { logger.info("Plugin is starting or running, skipping UI update") return } - + // Check if plugin is actually running val isPluginRunning = isPluginActuallyRunning() - + // If plugin is running, don't change the main UI content if (isPluginRunning) { logger.info("Plugin is running, keeping current UI content") return } - + contentPanel.removeAll() - + // Always show system info panel (Zoo Code is always configured) - + val initPanel = JPanel(BorderLayout()).apply { border = BorderFactory.createEmptyBorder(20, 20, 20, 20) add(placeholderLabel, BorderLayout.CENTER) @@ -959,33 +959,33 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { } contentPanel.add(initPanel, BorderLayout.CENTER) logger.info("Showing system info panel - Zoo Code auto-configured") - + contentPanel.revalidate() contentPanel.repaint() } - - + + /** * Show manual configuration instructions */ private fun showManualConfigInstructions() { val instructions = """ πŸ“ Manual Configuration Instructions - + 1. Create configuration file in user home directory: ${PluginConstants.ConfigFiles.getMainConfigPath()} 2. Add the following content: ${PluginConstants.ConfigFiles.EXTENSION_TYPE_KEY}=zoo-code - + 3. Supported plugin types: - zoo-code: Zoo Code AI Assistant - cline: Cline AI Assistant - custom: Custom Plugin - + 4. Save the file and restart IDE - + Configuration file path: ${configManager.getConfigurationFilePath()} """.trimIndent() - + javax.swing.JOptionPane.showMessageDialog( contentPanel, instructions, @@ -1001,18 +1001,18 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { val debugText = """ Zoo Code Debug Information ========================== - + πŸš€ Plugin Status: ${if (configManager.isConfigurationLoaded() && configManager.isConfigurationValid()) "Loaded and Valid" else "Not Loaded or Invalid"} - + πŸ“ Current Configuration: ${configManager.getCurrentExtensionId() ?: "Not Set"} - + βš™οΈ Configuration File Path: ${configManager.getConfigurationFilePath()} - + πŸ”„ Configuration Load Time: ${configManager.getConfigurationLoadTime()?.let { it.toString() } ?: "Unknown"} - + πŸ’‘ Tip: If configuration is invalid, please check the configuration file content or try manual configuration. """.trimIndent() - + javax.swing.JOptionPane.showMessageDialog( contentPanel, debugText, diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/JcefSupport.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/JcefSupport.kt new file mode 100644 index 0000000..4443f4c --- /dev/null +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/JcefSupport.kt @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package org.zoocode.jetbrains.util + +import com.intellij.ui.jcef.JBCefApp + +/** + * Outcome of the JBCefApp availability probe. + * + * [AVAILABLE] means JBCefApp.isSupported() returned true, so JCEF views can + * render. [RUNTIME_UNAVAILABLE] means the JBCefApp class is present but the + * probe returned false, which a runtime without JCEF support causes. + * [CLASSES_MISSING] means loading JBCefApp itself failed with a LinkageError; + * on IDE builds 2026.2 (262) and newer that means the bundled + * com.intellij.modules.jcef plugin is absent. + */ +enum class JcefSupport { + AVAILABLE, + RUNTIME_UNAVAILABLE, + CLASSES_MISSING; + + /** Only [AVAILABLE] can render JCEF views. */ + val isRenderable: Boolean + get() = this == AVAILABLE + + companion object { + /** Runs the JBCefApp.isSupported() probe and maps the outcome. */ + fun current(): JcefSupport = classify { JBCefApp.isSupported() } + + /** + * Test seam; production code calls [current]. Without the JCEF plugin + * the probe fails with NoClassDefFoundError, so only LinkageError maps + * to [CLASSES_MISSING] and every other probe failure propagates. + */ + internal fun classify(isSupported: () -> Boolean): JcefSupport = + try { + if (isSupported()) AVAILABLE else RUNTIME_UNAVAILABLE + } catch (_: LinkageError) { + CLASSES_MISSING + } + } +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/webview/WebViewManager.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/webview/WebViewManager.kt index 73178d5..e1dfebb 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/webview/WebViewManager.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/webview/WebViewManager.kt @@ -17,6 +17,8 @@ import com.intellij.ui.jcef.JBCefBrowser import com.intellij.ui.jcef.JBCefJSQuery import org.zoocode.jetbrains.core.PluginContext import org.zoocode.jetbrains.core.ServiceProxyRegistry +import org.zoocode.jetbrains.util.JcefSupport +import org.zoocode.jetbrains.util.NotificationUtil import org.zoocode.jetbrains.events.WebviewHtmlUpdateData import org.zoocode.jetbrains.events.WebviewViewProviderData import org.zoocode.jetbrains.ipc.proxy.SerializableObjectWithBuffers @@ -66,20 +68,20 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { // Latest created WebView instance @Volatile private var latestWebView: WebViewInstance? = null - + // Store WebView creation callbacks private val creationCallbacks = mutableListOf() // Resource root directory path @Volatile private var resourceRootDir: Path? = null - + // Current theme configuration private var currentThemeConfig: JsonObject? = null - + // Current theme type private var isDarkTheme: Boolean = true - + // Prevent repeated dispose private var isDisposed = false private var themeInitialized = false @@ -90,14 +92,14 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { */ fun initializeThemeManager(resourceRoot: String) { if (isDisposed or themeInitialized) return - + logger.info("Initialize theme manager") val themeManager = ThemeManager.getInstance() themeManager.initialize(resourceRoot) themeManager.addThemeChangeListener(this) themeInitialized = true } - + /** * Implement ThemeChangeListener interface, handle theme change events */ @@ -105,17 +107,17 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { logger.info("Received theme change event, isDarkTheme: $isDarkTheme, config: ${themeConfig.size()}") this.currentThemeConfig = themeConfig this.isDarkTheme = isDarkTheme - + // Send theme config to all WebView instances sendThemeConfigToWebViews(themeConfig) } - + /** * Send theme config to all WebView instances */ private fun sendThemeConfigToWebViews(themeConfig: JsonObject) { logger.info("Send theme config to WebView") - + // getAllWebViews().forEach { webView -> try { getLatestWebView()?.sendThemeConfigToWebView(themeConfig) @@ -124,7 +126,7 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { } // } } - + /** * Save HTML content to resource directory * @param html HTML content @@ -136,9 +138,9 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { logger.warn("Resource root directory does not exist, cannot save HTML content") throw IOException("Resource root directory does not exist") } - + val filePath = resourceRootDir?.resolve(filename) - + try { if (filePath != null) { logger.info("HTML content saved to: $filePath") @@ -151,7 +153,7 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { throw e } } - + /** * Register WebView creation callback * @param callback Callback object @@ -160,7 +162,7 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { fun addCreationCallback(callback: WebViewCreationCallback, disposable: Disposable? = null) { synchronized(creationCallbacks) { creationCallbacks.add(callback) - + // If Disposable is provided, automatically remove callback when disposed if (disposable != null) { Disposer.register(disposable, Disposable { @@ -168,7 +170,7 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { }) } } - + // If there is already a latest created WebView, notify immediately latestWebView?.let { webview -> ApplicationManager.getApplication().invokeLater { @@ -176,7 +178,7 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { } } } - + /** * Remove WebView creation callback * @param callback Callback object to remove @@ -186,7 +188,7 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { creationCallbacks.remove(callback) } } - + /** * Notify all callbacks that WebView has been created * @param instance Created WebView instance @@ -195,7 +197,7 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { val callbacks = synchronized(creationCallbacks) { creationCallbacks.toList() // Create a copy to avoid concurrent modification } - + // Safely call callbacks in UI thread ApplicationManager.getApplication().invokeLater { callbacks.forEach { callback -> @@ -207,33 +209,33 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { } } } - + /** * Register WebView provider and create WebView instance */ fun registerProvider(data: WebviewViewProviderData) { logger.info("Register WebView provider and create WebView instance: ${data.viewType}") val extension = data.extension - + // Get location info from extension and set resource root directory try { @Suppress("UNCHECKED_CAST") val location = extension?.get("location") as? Map val fsPath = location?.get("fsPath") as? String - + if (fsPath != null) { // Set resource root directory val path = Paths.get(fsPath) logger.info("Get resource directory path from extension: $path") - + // Ensure the resource directory exists if (!path.exists()) { path.createDirectories() } - + // Update resource root directory resourceRootDir = path - + // Initialize theme manager initializeThemeManager(fsPath) @@ -247,12 +249,24 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { logger.error("Cannot get RPC protocol instance, cannot register WebView provider: ${data.viewType}") return } + val jcefState = JcefSupport.current() + if (!jcefState.isRenderable) { + logger.error("JCEF is not usable ($jcefState); refusing to create WebView: ${data.viewType}") + NotificationUtil.showWarning( + "Zoo Code", + "Zoo Code needs the IDE embedded browser (JCEF), which this IDE does not provide. " + + "The Zoo Code view cannot open.", + project + ) + return + } + // When registration event is notified, create a new WebView instance val viewId = UUID.randomUUID().toString() val title = data.options["title"] as? String ?: data.viewType val state = data.options["state"] as? Map ?: emptyMap() - + val webview = WebViewInstance(data.viewType, viewId, title, state,project,data.extension) val proxy = protocol.getProxy(ServiceProxyRegistry.ExtHostContext.ExtHostWebviewViews) @@ -261,13 +275,13 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { // Set as the latest created WebView latestWebView = webview - + logger.info("Create WebView instance: viewType=${data.viewType}, viewId=$viewId") // Notify callback notifyWebViewCreated(webview) } - + /** * Get the latest created WebView instance */ @@ -295,13 +309,13 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { const msgStr = JSON.stringify(message); ${getLatestWebView()?.jsQuery?.inject("msgStr")} }; - + // Inject VSCode API mock globalThis.acquireVsCodeApi = (function() { let acquired = false; - + let state = JSON.parse('${encodedState}'); - + if (typeof window !== "undefined" && !window.receiveMessageFromPlugin) { console.log("VSCodeAPIWrapper: Setting up receiveMessageFromPlugin for IDEA plugin compatibility"); window.receiveMessageFromPlugin = (message) => { @@ -313,7 +327,7 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { window.dispatchEvent(event); }; } - + return () => { if (acquired) { throw new Error('An instance of the VS Code API has already been acquired'); @@ -335,21 +349,21 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { }); }; })(); - + // Clean up references to window parent for security delete window.parent; delete window.top; delete window.frameElement; - + console.log("VSCode API mock injected"); """) logger.info("Received HTML update event: handle=${data.handle}, html length: ${data.htmlContent.length}") - + val webView = getLatestWebView() - + if (webView != null) { try { // If HTTP server is running @@ -394,14 +408,14 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { } } - + override fun dispose() { if (isDisposed) { logger.info("WebViewManager has already been disposed, ignoring repeated call") return } isDisposed = true - + logger.info("Releasing WebViewManager resources...") // Remove listener from theme manager @@ -410,7 +424,7 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { } catch (e: Exception) { logger.error("Failed to remove listener from theme manager", e) } - + // Clean up resource directory try { // Only delete index.html file, keep other files @@ -437,15 +451,15 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { } catch (e: Exception) { logger.error("Failed to release WebView resources", e) } - + // Reset theme data currentThemeConfig = null - + // Clear callback list synchronized(creationCallbacks) { creationCallbacks.clear() } - + logger.info("WebViewManager released") } @@ -464,7 +478,7 @@ class WebViewInstance( val extension: Map ) : Disposable { private val logger = Logger.getInstance(WebViewInstance::class.java) - + // JCEF browser instance val browser = JBCefBrowser.createBuilder().setOffScreenRendering(true).build() @@ -473,7 +487,7 @@ class WebViewInstance( private val zoomKeyEventDispatcher = java.awt.KeyEventDispatcher { event -> handleZoomKeyEvent(event) } - + // WebView state private var isDisposed = false @@ -489,10 +503,10 @@ class WebViewInstance( private var isPageLoaded = false private var currentThemeConfig: JsonObject? = null - + // Callback for page load completion private var pageLoadCallback: (() -> Unit)? = null - + init { setupJSBridge() setupZoomShortcuts() @@ -549,7 +563,7 @@ class WebViewInstance( fun isPageLoaded(): Boolean { return isPageLoaded } - + /** * Set callback for page load completion * @param callback Callback function to be called when page is loaded @@ -557,7 +571,7 @@ class WebViewInstance( fun setPageLoadCallback(callback: (() -> Unit)?) { pageLoadCallback = callback } - + private fun injectTheme() { if(currentThemeConfig == null) { return @@ -585,7 +599,7 @@ class WebViewInstance( // Extract CSS variables (format: --name:value;) const cssLines = `$cssContent`.split('\n'); const cssVariables = []; - + // Process each line, extract CSS variable declarations for (const line of cssLines) { const trimmedLine = line.trim(); @@ -598,17 +612,17 @@ class WebViewInstance( cssVariables.push(trimmedLine); } } - + // Merge extracted CSS variables into style attribute string const styleAttrValue = cssVariables.join(' '); - + // Set as style attribute of html tag document.documentElement.setAttribute('style', styleAttrValue); console.log("CSS variables set as style attribute of HTML tag"); } catch (error) { console.error("Error processing CSS variables:", error); } - + // Keep original default style injection logic if(document.head) { // Inject default theme style into head, use id="_defaultStyles" @@ -618,14 +632,14 @@ class WebViewInstance( defaultStylesElement.id = '_defaultStyles'; document.head.appendChild(defaultStylesElement); } - + // Add default_themes.css content defaultStylesElement.textContent = ` html { background: var(--vscode-sideBar-background); scrollbar-color: var(--vscode-scrollbarSlider-background) var(--vscode-sideBar-background); } - + body { overscroll-behavior-x: none; background-color: transparent; @@ -638,25 +652,25 @@ class WebViewInstance( overflow-x: hidden; /* prevent horizontal scrollbar */ overflow-y: auto; /* allow vertical scrolling only */ } - + img, video { max-width: 100%; height: auto; /* keep aspect ratio and avoid vertical overflow */ display: block; /* remove inline baseline gaps that can trigger overflow */ } - + a, a code { color: var(--vscode-textLink-foreground); } - + p > a { text-decoration: var(--text-link-decoration); } - + a:hover { color: var(--vscode-textLink-activeForeground); } - + a:focus, input:focus, select:focus, @@ -664,7 +678,7 @@ class WebViewInstance( outline: 1px solid -webkit-focus-ring-color; outline-offset: -1px; } - + code { font-family: var(--monaco-monospace-font); color: var(--vscode-textPreformat-foreground); @@ -672,16 +686,16 @@ class WebViewInstance( padding: 1px 3px; border-radius: 4px; } - + pre code { padding: 0; } - + blockquote { background: var(--vscode-textBlockQuote-background); border-color: var(--vscode-textBlockQuote-border); } - + kbd { background-color: var(--vscode-keybindingLabel-background); color: var(--vscode-keybindingLabel-foreground); @@ -694,19 +708,19 @@ class WebViewInstance( vertical-align: middle; padding: 1px 3px; } - + ::-webkit-scrollbar { width: 10px; height: 10px; } - + ::-webkit-scrollbar-corner { background-color: var(--vscode-editor-background); } - + *, *::before, *::after { box-sizing: border-box; } html, body { width: 100%; height: 100%; } - + ::-webkit-scrollbar-thumb { background-color: var(--vscode-scrollbarSlider-background); } @@ -839,7 +853,7 @@ class WebViewInstance( return true } }, browser.cefBrowser) - + // Register load handler client.addLoadHandler(object : CefLoadHandlerAdapter() { override fun onLoadingStateChange( @@ -850,7 +864,7 @@ class WebViewInstance( ) { logger.info("WebView loading state changed: isLoading=$isLoading, canGoBack=$canGoBack, canGoForward=$canGoForward") } - + override fun onLoadStart( browser: CefBrowser?, frame: CefFrame?, @@ -859,7 +873,7 @@ class WebViewInstance( logger.info("WebView started loading: ${frame?.url}, transition type: $transitionType") isPageLoaded = false } - + override fun onLoadEnd( browser: CefBrowser?, frame: CefFrame?, @@ -871,7 +885,7 @@ class WebViewInstance( // Notify page load completion pageLoadCallback?.invoke() } - + override fun onLoadError( browser: CefBrowser?, frame: CefFrame?, @@ -923,7 +937,7 @@ class WebViewInstance( logger.error("Failed to enable WebView resource interception", e) } } - + /** * Load URL */ @@ -933,7 +947,7 @@ class WebViewInstance( browser.loadURL(url) } } - + /** * Load HTML content */ @@ -947,7 +961,7 @@ class WebViewInstance( } } } - + /** * Execute JavaScript */ @@ -957,7 +971,7 @@ class WebViewInstance( browser.cefBrowser.executeJavaScript(script, browser.cefBrowser.url, 0) } } - + /** * Open developer tools */ @@ -966,7 +980,7 @@ class WebViewInstance( browser.openDevtools() } } - + override fun dispose() { if (!isDisposed) { KeyboardFocusManager.getCurrentKeyboardFocusManager() diff --git a/jetbrains_plugin/src/main/resources/META-INF/ZooCode-jcef.xml b/jetbrains_plugin/src/main/resources/META-INF/ZooCode-jcef.xml new file mode 100644 index 0000000..3287bec --- /dev/null +++ b/jetbrains_plugin/src/main/resources/META-INF/ZooCode-jcef.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/jetbrains_plugin/src/main/resources/META-INF/plugin.xml b/jetbrains_plugin/src/main/resources/META-INF/plugin.xml index 1ae4e2d..45e4402 100644 --- a/jetbrains_plugin/src/main/resources/META-INF/plugin.xml +++ b/jetbrains_plugin/src/main/resources/META-INF/plugin.xml @@ -120,8 +120,12 @@ SPDX-License-Identifier: Apache-2.0 Read more: https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html --> com.intellij.modules.platform - com.intellij.modules.jcef + The id com.intellij.modules.jcef is the JCEF plugin id there and no module at all + before 262, so the dependency must stay optional. An optional depends requires a + companion config-file descriptor; it stays empty because no extension point of this + plugin depends on the JCEF plugin, and moving extensions here would hide them from + every IDE below 262, where the dependency never resolves. --> + com.intellij.modules.jcef org.jetbrains.plugins.terminal