From c2bbe3b2097e5102c55e5cc8a310e79f26914563 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 27 Oct 2026 00:43:29 +0000 Subject: [PATCH] fix(jetbrains): keep chat input focus while the agent works --- .github/workflows/pr.yml | 13 +- docs/KNOWN_ISSUES.md | 59 +- docs/validation/chat-focus-preservation.md | 230 ++++++ jetbrains_plugin/build.gradle.kts | 6 + .../scripts/ui-harness/.gitignore | 1 + .../scripts/ui-harness/HarnessDriver.java | 670 ++++++++++++++++++ .../scripts/ui-harness/KeyDropProbe.java | 199 ++++++ jetbrains_plugin/scripts/ui-harness/README.md | 94 +++ .../scripts/ui-harness/plugin.xml | 13 + .../scripts/ui-harness/run-solheim.sh | 133 ++++ .../scripts/ui-harness/solheim_proxy.py | 129 ++++ .../scripts/ui-harness/test_solheim_proxy.py | 109 +++ .../actors/MainThreadTerminalServiceShape.kt | 5 +- .../actors/MainThreadTextEditorsShape.kt | 8 +- .../actors/MainThreadWebviewViewsShape.kt | 71 +- .../jetbrains/editor/EditorAndDocManager.kt | 61 +- .../jetbrains/editor/EditorCommands.kt | 68 +- .../jetbrains/terminal/TerminalInstance.kt | 141 +++- .../ui/ZooCodeJetBrainsToolWindowFactory.kt | 236 ++++-- .../jetbrains/util/FocusRequestEpoch.kt | 30 + .../jetbrains/util/ManualFocusGuard.kt | 77 ++ .../util/TerminalCreationFocusGuard.kt | 120 ++++ .../jetbrains/webview/WebViewManager.kt | 357 +++++++++- .../jetbrains/editor/EditorFocusIntentTest.kt | 106 +++ .../terminal/TerminalWidgetVisibilityTest.kt | 86 +++ .../jetbrains/ui/WebViewMountPlanTest.kt | 100 +++ .../jetbrains/util/FocusRequestEpochTest.kt | 87 +++ .../jetbrains/util/ManualFocusGuardTest.kt | 101 +++ .../util/TerminalCreationFocusGuardTest.kt | 196 +++++ .../webview/InstanceRetirementTrackerTest.kt | 192 +++++ 30 files changed, 3562 insertions(+), 136 deletions(-) create mode 100644 docs/validation/chat-focus-preservation.md create mode 100644 jetbrains_plugin/scripts/ui-harness/.gitignore create mode 100644 jetbrains_plugin/scripts/ui-harness/HarnessDriver.java create mode 100644 jetbrains_plugin/scripts/ui-harness/KeyDropProbe.java create mode 100644 jetbrains_plugin/scripts/ui-harness/README.md create mode 100644 jetbrains_plugin/scripts/ui-harness/plugin.xml create mode 100644 jetbrains_plugin/scripts/ui-harness/run-solheim.sh create mode 100644 jetbrains_plugin/scripts/ui-harness/solheim_proxy.py create mode 100644 jetbrains_plugin/scripts/ui-harness/test_solheim_proxy.py create mode 100644 jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/FocusRequestEpoch.kt create mode 100644 jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/ManualFocusGuard.kt create mode 100644 jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/TerminalCreationFocusGuard.kt create mode 100644 jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/editor/EditorFocusIntentTest.kt create mode 100644 jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/terminal/TerminalWidgetVisibilityTest.kt create mode 100644 jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/ui/WebViewMountPlanTest.kt create mode 100644 jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/util/FocusRequestEpochTest.kt create mode 100644 jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/util/ManualFocusGuardTest.kt create mode 100644 jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/util/TerminalCreationFocusGuardTest.kt create mode 100644 jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/webview/InstanceRetirementTrackerTest.kt diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 24c5437..916504b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -87,10 +87,12 @@ jobs: mkdir -p plugins/zoo-code/extension touch plugins/zoo-code/extension/.keep prodDep.txt - # The connector tests are pure JVM tests and the focus of these gates. + # The connector and focus tests are pure JVM tests and the focus of + # these gates. The Gradle test task sets java.awt.headless=true, so the + # Swing-based focus regression tests initialize on the headless runner. # The platform-fixture tests need a display and the extension payload, # which a pull-request build does not assemble. - - name: Run the connector tests + - name: Run the connector and focus tests working-directory: jetbrains_plugin shell: bash run: > @@ -101,6 +103,13 @@ jobs: --tests "org.zoocode.jetbrains.terminal.TerminalPtyBuilderTest" --tests "org.zoocode.jetbrains.terminal.ShellIntegrationOutputStateTest" --tests "org.zoocode.jetbrains.terminal.TerminalShellReadinessTest" + --tests "org.zoocode.jetbrains.editor.EditorFocusIntentTest" + --tests "org.zoocode.jetbrains.terminal.TerminalWidgetVisibilityTest" + --tests "org.zoocode.jetbrains.ui.WebViewMountPlanTest" + --tests "org.zoocode.jetbrains.util.FocusRequestEpochTest" + --tests "org.zoocode.jetbrains.util.ManualFocusGuardTest" + --tests "org.zoocode.jetbrains.util.TerminalCreationFocusGuardTest" + --tests "org.zoocode.jetbrains.webview.InstanceRetirementTrackerTest" --no-configuration-cache - name: Build the plugin diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 9f9beb3..21021d9 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -168,7 +168,62 @@ The plugin requires Node.js command line tools to function properly. Node.js exe --- -## 3. General Troubleshooting Steps +## 3. Focus Behavior + +Measured triggers, fixes, and remaining limits are recorded in +[the validation report](validation/chat-focus-preservation.md). Raw evidence +paths in that report are local, ignored build artifacts; the report includes +the relevant trace excerpts for readers of a clean checkout. + +### 3.1 Terminal Creation Focus Restoration + +Production terminal creation activates the new terminal and can move Swing +focus from the chat OSR canvas to `JBTerminalPanel`. +`TerminalCreationFocusGuard` captures the exact chat owner before +initialization and restores that owner once after the platform activation +settles. It restores only when focus ended inside the newly created terminal, +chat remains visible, and no user click, key, or Tab traversal intervened. The +guard expires at the callback or 1500 ms after creation; there is no polling or +permanent listener. Instrumented probes retain pre- and post-creation typing +with the owner back on the chat OSR canvas, and pure decision tests are in the +CI allowlist. + +Deliberate Terminal user actions are expected platform behavior, not defects. +The first show of the Terminal tool window, stock new-tab actions outside the +plugin-owned creation path, and stock tab selection request terminal focus by +design. The plugin does not wrap these stock actions. The preserving-show and +deliberate-show harness checks for these paths remain required. macOS, native +Windows, and other IDE builds remain untested. + +### 3.2 Corrected Test Fixture Items + +Two earlier harness failures were test-setup defects, not current product +issues: an IDEA script-launcher warning balloon covered the chat textarea, and +a harness step clicked the browser retired during webview replacement. The +fixture now uses the native launcher, waits for smart mode, and references the +currently mounted browser. No balloon-restoration or permanent focus code was +added to the plugin. Modal dismissal and visibility-only hide/show still do not +automatically activate chat. + +### 3.3 Intermittent First-Turn Draft Character Loss + +Open. Not fixed. Not conclusively attributed. Real provider-backed +conversations intermittently lose one draft character near the first +file-editor or diff opening. The latest run completed both turns but failed +draft preservation: 32/33 characters in the first turn, 6/6 in the follow-up. +The IDE trace recorded the key release without a matching press, with no +browser remount and the Swing owner stable on the chat OSR canvas. The +initiating platform call remains unproven. The same test stream found and fixed +the separate diff `preserveFocus` decoding bug; the live diff now opens with +`preserveFocus=true` and chat focus retained. A pure JDK probe +(`scripts/ui-harness/KeyDropProbe.java`) reproduced release-without-press only +under deliberate focus churn (4/120; control 0/120); it is analogous evidence, +not an IDE reproduction. A later passing run does not clear this issue. +Observed only in the Linux Xvfb/Openbox fixture on IU-253.28294.334. Other +platforms, builds, and live click races are untested. Trace excerpts and +failed-run references are in [the validation report](validation/chat-focus-preservation.md). + +## 4. General Troubleshooting Steps If you encounter any of the above issues, follow these general troubleshooting steps: @@ -180,7 +235,7 @@ If you encounter any of the above issues, follow these general troubleshooting s --- -## 4. Reporting New Issues +## 5. Reporting New Issues If you encounter issues not documented here, please report them with the following information: diff --git a/docs/validation/chat-focus-preservation.md b/docs/validation/chat-focus-preservation.md new file mode 100644 index 0000000..606eaf2 --- /dev/null +++ b/docs/validation/chat-focus-preservation.md @@ -0,0 +1,230 @@ +# Chat focus validation summary + +Status 2026-10-17. Two plugin-owned focus defects are fixed and test-covered: +terminal creation stealing chat focus, and `preserveFocus` dropped by the diff +RPC decoder. The intermittent first-turn draft loss during real conversations +is **open**: not fixed, and not conclusively attributed. `docs/KNOWN_ISSUES.md` +links this file. Raw evidence paths below are relative to +`jetbrains_plugin/build/ui-harness/evidence/`. Those directories are local, +ignored artifacts kept in the investigation workspace; this file carries the +decisive excerpts for a clean checkout. + +## Plugin-owned fix 1: bounded terminal-creation focus restoration + +Proven trigger. `TerminalInstance.addToTerminalToolWindow` calls +`TerminalToolWindowManager.newTab`, which inserts and selects the new content +with focus enabled. The platform then moves Swing focus from the chat +`JBCefOsrComponent` to `JBTerminalPanel`, and typed text goes to the terminal. +The bytecode pin from the actual IDE (IU-253.28294.334) is in +`terminal-stack-baseline/terminal-manager-bytecode.txt`: + +```text +newTab: iconst_1; iconst_1; invokevirtual createNewTab(..., boolean, boolean) +createNewTab: ToolWindow.activate(runnable, true, true) +selection runnable: ContentManager.setSelectedContent(content, requestFocus) +``` + +The transition trace in `terminal-stack-baseline/progress.log` records T7 +production creation before the fix. The intended post-creation text was +`createpost`; none reached chat. + +```text +BATTERY-MARK ts=40771 step=T7-production-preserving-show +FOCUS-TIME ts=40796 prop=focusOwner old=com.intellij.ui.jcef.JBCefOsrComponent new=null +FOCUS-TIME ts=40797 prop=focusOwner old=null new=com.intellij.terminal.JBTerminalPanel +RECORD T7_creationFocusOwner = com.intellij.terminal.JBTerminalPanel +``` + +Fix. `TerminalCreationFocusGuard` captures the exact Swing chat owner before +terminal initialization. After creation it joins `ToolWindowManager.invokeLater` +and waits once for native focus to settle. It restores the captured component +once, only if focus ended inside the newly created terminal, chat stays visible, +the project is alive, and no user click, key, or Tab traversal intervened. +Focus in an editor or balloon is left alone. Input observation ends at the +callback or 1500 ms after creation. There is no polling loop, permanent +listener, repeated request, or unconditional restoration. Manager shutdown uses +a volatile flag, and refused JCEF registrations are disposed on the EDT outside +the ownership lock. The deliberate-show `ManualFocusGuard` is unchanged except +for documenting this bounded exception. + +Ordering evidence. `terminal-inline-callback-diagnostic/idea.log` records the +focus-settled callback before platform activation seeing the chat owner and +returning `restore=false`. `terminal-queued-callback-diagnostic/idea.log` +records the callback after the tool-window queue seeing `JBTerminalPanel` and +returning `restore=true` for T2 and T7, with exact typing probes passing. Later +attempts of that diagnostic died to system OOM +(`terminal-final-oom/`, `terminal-reduced-heap-oom/`); T7 observations after a +renderer kill are invalid, not regressions or passes. Final runs execute the +batteries sequentially and retain intermediate failures. + +## Plugin-owned fix 2: diff preserveFocus decoding + +The bundled extension passes `{preserveFocus:true}` to `vscode.diff`, but the +extension host wraps the options in a tuple +(`deps/vscode/src/vs/workbench/api/common/extHostApiCommands.ts:459`: +`[ViewColumn.from(...), TextEditorOpenOptions.from(...)]`). The Kotlin decoder +accepted only a map, so the real task logged `Opening diff, preserveFocus=false` +and the focus recorder caught an editor transition at ts=113909 with return to +the chat canvas at ts=113974. `decodeDiffPreserveFocus` now unwraps the tuple's +options member and still accepts direct-map callers. Missing or malformed flags +keep the existing false default. Four unit cases pin true, false, missing +options, and malformed input. + +Bundled extension note. The bundled Zoo Code extension 3.84.0 command runner +calls `terminal.show(true)` before `runCommand` (bundle SHA-256 +`2fe6fbde2ba7bc67e0c2e3f7eed62246da3cd9554a1ae2b9090faa1a7a0385b7`), so the +changed omitted-argument default does not force terminal focus on that path. +Explicit false or an omitted argument from another caller still requests +terminal focus. + +## Test status + +The CI allowlist command in `.github/workflows/pr.yml` passes with **104 tests, +0 failures, 0 errors, 0 skipped** (local output `review-3-unit-tests.log`). +Historical 86 and 100 counts describe earlier builds. The local relay tests +pass (`python3 -m unittest test_solheim_proxy.py`; twelve cases after the +run-4 relay hardening). Shell syntax and diff whitespace checks pass. The full +platform-fixture suite was not rerun; its previously documented failures are +not claimed fixed. + +## Harness results + +Live-run environment: harness IDE IU-253.28294.334, Linux Xvfb with Openbox, +heap 768 MB, 4 CPUs, plugin zip SHA-256 +`4d9b34ca46c4bbe0aba7637181e697da4f1bcbb96590d87efdf99c72ab3865e4` unless +noted. Conversation runs use the real bundled extension, the real React +textarea, real tool execution, and Solheim `qwen3.8-27b` through the +loopback-only relay (`scripts/ui-harness/solheim_proxy.py`). No simulated +provider responses or injected chat input are used for the conversation battery. + +Corrected runs on plugin-owned paths: + +- Mode F: PASS. Typing reaches the input with the OSR canvas focused + (`typedWithCanvasFocusVerdict=true`) and stops when Swing focus moves + elsewhere (`keysLostWhenFocusMovedVerdict=true`). Evidence: `f/`. +- Mode T: plugin-owned paths pass. T2 production creation, T2R streaming and + editor replay, T3 visibility-only show, T7 creation and preserving show, and + T8 deliberate show. Evidence: `t/`. Stock first-show (T1) and stock + new-tab/selection (T4) still focus the terminal; see the classification + below. +- Mode G, corrected final run: PASS. G6 manual click recovery, G7 preserving + editor bridge with exact owner identity and post-bridge typing, and G8 + disposal typing. G1 reload and G5/G6 raw hide-show without manual refocus + still do not type; recorded as-is. Evidence: `g/`. The earlier invalid run + that clicked the retired browser is `g-native-stale-browser/`. +- Mode P, final instrumented run: PASS in 103 seconds. Thirty exact first-turn + samples and five exact follow-up samples; history grew 21 then 29 messages; + three real commands exited 0; the diff logged `preserveFocus=true`. Evidence: + `p/`. This 35/35 pass is historical; the latest conversation run below + supersedes it. + +Deliberate stock user actions, classified separately. The first Terminal +tool-window show and stock tab-selection and new-session paths move focus to +the terminal by platform design. The plugin does not wrap these stock actions, +and this report does not classify them as defects. The preserving-show and +deliberate-show harness checks for these paths remain required. + +Fixture corrections, not product issues. The IDEA script-launcher warning +balloon covered the textarea in early runs; the fixture now uses the native +launcher. One G prerequisite clicked the browser retired during webview +replacement; the harness now references the mounted browser. The driver also +waits for smart mode and a showing browser before its setup click. These were +test-setup defects. No balloon-restoration or permanent focus code was added to +the plugin. + +## Latest real provider run: FAIL + +**FAIL-REAL-CONVERSATION**, 123 seconds, on the same executable plugin +artifact. Both real turns completed; history grew, commands ran, the fixture +changed, and the diff opened with `preserveFocus=true`. Draft preservation +failed: the first turn kept **32 of 33** characters, the follow-up kept **6 of +6**. The first mismatch is sample 16 (`snapshot=19`): 15 actual characters +against 16 expected, with the chat still active. Raw evidence: +`review-3-standalone-p/`; earlier startup failures: +`review-3-standalone-startup-{1,2,3}/`. + +No browser remount occurred around the loss: the only mount and repeated-mount +logs are at 01:05:59 and 01:06:00, and the diff opened at 01:06:46.305. The +Swing owner stayed on the chat OSR canvas. The affected sample logs only the +key release in the IDE PRE/POST trace: + +```text +E2EAWT PRE ts=1791940006381 when=1791940006326 id=402 key=88 char=120 consumed=false owner=com.intellij.ui.jcef.JBCefOsrComponent@5f8aec10 +E2EAWT POST ts=1791940006382 when=1791940006326 id=402 key=88 consumed=false source=com.intellij.ui.jcef.JBCefOsrComponent@5f8aec10 +RECORD P_expected_16 = xxxxxxxxxxxxxxxx +``` + +No matching key press or DOM keydown or input arrived for that sample. The +trace narrows the gap to delivery before the recorded IDE preprocessors. It +does not prove which platform call caused it. + +Two earlier real runs on this build each lost one character near the first +file-editor opening (`p-tuple-fix-one-key-loss/`, `p-dom-key-loss/`); DOM +tracing there showed no textarea replacement and no blur. An intermediate +complete run is preserved as FAIL for a stale probe sample +(`p-complete-stale-probe/`), and a partial first attempt as +`p-native-partial-first/`. + +## JDK probe: analogous evidence and its limits + +`jetbrains_plugin/scripts/ui-harness/KeyDropProbe.java` is a JDK-only probe +with no IDE, plugin, or JCEF code. It types with `java.awt.Robot` and counts +press, typed, and release deliveries. In a local manual run, deliberate focus +churn produced release-without-press **4 times in 120 keys**, while the control +mode produced **0 in 120**. The probe's churn styles change the focus owner; +the IDE trace above kept the owner stable. The probe therefore shows that a +release can arrive without its press under focus churn in a pure JDK and Swing +stack. It does not reproduce the IDE mechanism, does not identify the +initiating IDE platform call, and does not prove a JDK root cause or clear the +product defect. Single runs are nondeterministic; compare control and churn on +the same host in the same session. + +## Remaining validation gaps + +- The intermittent first-turn draft loss is **open**: no fix, no proven + initiating call, and later passing runs do not clear it. +- macOS, native Windows, and other IDE builds are untested. The live + preserving-diff chain is exercised on build 253 only; builds 233/263 are + untested. +- Live user-click races during the guard window are untested. Controlled guard + lifecycle tests cover clicks, Tab traversal, expiry, repeated callbacks, + hidden chat, and disposal before or while queued. +- The full platform-fixture suite was not rerun; its previously documented + failures are not claimed fixed. + +## Reproduction + +Build and harness modes T/G/F/P: + +```sh +cd jetbrains_plugin +./gradlew buildPlugin -PdebugMode=release -PvscodePlugin=zoo-code --no-configuration-cache +cd build/ui-harness +unzip -oq ../distributions/ZooCode-3.78.0.zip -d plugins-dist +HARNESS_HEAP_MB=768 HARNESS_CPU_COUNT=4 IDE_DIR=$PWD/ide-253 DEADLINE_SECONDS=600 bash bin/run-test.sh t +HARNESS_HEAP_MB=768 HARNESS_CPU_COUNT=4 IDE_DIR=$PWD/ide-253 DEADLINE_SECONDS=600 bash bin/run-test.sh g +HARNESS_HEAP_MB=768 HARNESS_CPU_COUNT=4 IDE_DIR=$PWD/ide-253 DEADLINE_SECONDS=600 bash bin/run-test.sh f +``` + +The live provider conversation needs the relay and key setup from +`jetbrains_plugin/scripts/ui-harness/README.md`. The JDK probe needs only Xvfb +and a headful JDK: + +```sh +cd jetbrains_plugin/scripts/ui-harness +xvfb-run -a /path/to/headful-jdk/bin/java KeyDropProbe.java control 120 7 +xvfb-run -a /path/to/headful-jdk/bin/java KeyDropProbe.java churn 120 7 hidden-frame +``` + +## Evidence index (local, ignored) + +Under `jetbrains_plugin/build/ui-harness/evidence/`: `review-3-standalone-p/` +(latest FAIL), `review-3-standalone-startup-{1,2,3}/`, `p/` (historical +instrumented pass), `p-tuple-fix-one-key-loss/`, `p-dom-key-loss/`, +`p-complete-stale-probe/`, `p-native-partial-first/`, +`terminal-stack-baseline/`, `terminal-replay-baseline/`, +`terminal-inline-callback-diagnostic/`, +`terminal-queued-callback-diagnostic/`, `terminal-final-oom/`, +`terminal-reduced-heap-oom/`, `t/`, `g/`, `f/`, `g-native-stale-browser/`, +unit XML in `review-fixes-unit-results/` and +`terminal-creation-unit-results/`, and earlier reports `FOCUS_FIX_REPORT*.md`. diff --git a/jetbrains_plugin/build.gradle.kts b/jetbrains_plugin/build.gradle.kts index d58013b..609f245 100644 --- a/jetbrains_plugin/build.gradle.kts +++ b/jetbrains_plugin/build.gradle.kts @@ -498,6 +498,12 @@ ktlint { } } +// Run unit tests without a display; Swing components used by the regression +// tests must initialize in headless mode +tasks.named("test") { + systemProperty("java.awt.headless", "true") +} + // Configure detekt detekt { toolVersion = "1.23.4" diff --git a/jetbrains_plugin/scripts/ui-harness/.gitignore b/jetbrains_plugin/scripts/ui-harness/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/jetbrains_plugin/scripts/ui-harness/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/jetbrains_plugin/scripts/ui-harness/HarnessDriver.java b/jetbrains_plugin/scripts/ui-harness/HarnessDriver.java new file mode 100644 index 0000000..49d774e --- /dev/null +++ b/jetbrains_plugin/scripts/ui-harness/HarnessDriver.java @@ -0,0 +1,670 @@ +package org.zoocode.harnessdriver; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.intellij.ide.AppLifecycleListener; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowManager; +import com.intellij.util.concurrency.AppExecutorUtil; + +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.WindowEvent; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * TEST-ONLY harness driver. Installed only into disposable harness IDE profiles + * under jetbrains_plugin/build/ui-harness. Never shipped, never registered by the + * Zoo Code plugin. Observes the Zoo Code tool window and records evidence. + * All Zoo plugin and JCEF classes are accessed by reflection so this plugin keeps + * loading when the JCEF classes are absent. + */ +public final class HarnessDriver implements AppLifecycleListener { + private static final Logger LOG = Logger.getInstance(HarnessDriver.class); + private static final String TOOL_WINDOW_ID = "Zoo Code JetBrains"; + // The only commands the fixture prompts ask for. Anything else fails the run. + private static final java.util.regex.Pattern FIXTURE_COMMAND = java.util.regex.Pattern.compile( + "printf SOLHEIM_(FIRST|SECOND|THIRD)(; sleep [0-9]+)?(; cat src/Main\\.java)?"); + private long lastApprovedTs = -1; + + private final Object evidenceLock = new Object(); + private Path evidenceDir; + private Path progressLog; + private List checks = new ArrayList<>(); + + @Override + public void appFrameCreated(List commandLineArgs) { + String dir = System.getProperty("zoo.harness.evidence"); + if (dir == null) { + return; + } + evidenceDir = Paths.get(dir); + progressLog = evidenceDir.resolve("progress.log"); + log("app frame created, harness driver active, args=" + commandLineArgs); + AppExecutorUtil.getAppExecutorService().submit(() -> dismissStartupDialogs(300)); + AppExecutorUtil.getAppExecutorService().submit(this::waitForProjectAndRun); + } + + private void waitForProjectAndRun() { + String mode = "P"; + long deadline = System.currentTimeMillis() + 600_000; + Project project = null; + while (System.currentTimeMillis() < deadline) { + Project[] open = com.intellij.openapi.project.ProjectManager.getInstance().getOpenProjects(); + if (open.length > 0 && open[0].isOpen() && !open[0].isDisposed()) { + project = open[0]; + break; + } + try { + Thread.sleep(1_000); + } catch (InterruptedException e) { + return; + } + } + if (project == null) { + log("no project opened within timeout"); + return; + } + log("project detected: " + project.getName() + ", mode=" + mode); + run(project, mode); + } + + private void run(Project project, String mode) { + log("driver started, mode=" + mode); + try { + record("jcefState", reflectJcefSupport()); + record("ideBuild", com.intellij.openapi.application.ApplicationInfo.getInstance().getBuild().asString()); + record("javaVersion", System.getProperty("java.version")); + + ToolWindow tw = (ToolWindow) poll(120_000, () -> { + ToolWindow w = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID); + return w != null ? w : null; + }); + if (tw == null) { + fail("toolWindowNotFound", "tool window " + TOOL_WINDOW_ID + " did not appear"); + return; + } + runOnEdt(() -> tw.show(null)); + waitEdt(); + Thread.sleep(2_000); + screenshot("01-toolwindow-shown"); + record("toolWindowShown", true); + + runRealConversation(project); + } catch (Throwable t) { + fail("driverException", String.valueOf(t)); + } + } + + // ---- Test A: real JCEF browser render plus JS round trip --------------- + + private ClassLoader zooClassLoader() throws Exception { + Object plugin = com.intellij.ide.plugins.PluginManagerCore.getPlugin( + com.intellij.openapi.extensions.PluginId.getId("ZooCode")); + if (plugin == null) { + throw new IllegalStateException("ZooCode plugin not found"); + } + return (ClassLoader) plugin.getClass().getMethod("getPluginClassLoader").invoke(plugin); + } + + private Class zooClass(String name) throws Exception { + return Class.forName(name, true, zooClassLoader()); + } + + private boolean addTitleHandler(Object jbCefBrowser, Object cefBrowser, AtomicReference capturedTitle) { + try { + Object jbCefClient = invoke(jbCefBrowser, "getJBCefClient"); + Class handlerIfc = Class.forName("org.cef.handler.CefDisplayHandler"); + Object proxy = Proxy.newProxyInstance(handlerIfc.getClassLoader(), new Class[]{handlerIfc}, + (p, m, a) -> { + if ("onTitleChange".equals(m.getName()) && a.length >= 2 && a[1] instanceof String) { + capturedTitle.set((String) a[1]); + if (((String)a[1]).startsWith("ZFE2E:")) { + JsonObject reply=JsonParser.parseString(((String)a[1]).substring(6)).getAsJsonObject(); + realReplies.put(reply.get("snapshot").getAsInt(),reply); + } + log("TITLE-EVENT " + a[1]); + } + if ("onConsoleMessage".equals(m.getName()) && a.length >= 4) { + String msg = String.valueOf(a[2]); + log("PAGE-CONSOLE " + a[1] + " " + msg); + if (msg.startsWith("ZFC:")) { + consoleLines.add(msg); + } + } + Class rt = m.getReturnType(); + if (rt == boolean.class) { + return Boolean.FALSE; + } + if (rt == int.class) { + return 0; + } + if (rt == long.class) { + return 0L; + } + return null; + }); + for (Method m : jbCefClient.getClass().getMethods()) { + if (m.getName().equals("addDisplayHandler")) { + Class[] pt = m.getParameterTypes(); + if (pt.length == 2 && pt[0].isAssignableFrom(handlerIfc)) { + m.invoke(jbCefClient, proxy, cefBrowser); + return true; + } + } + } + LOG.warn("addDisplayHandler not found on " + jbCefClient.getClass()); + return false; + } catch (Throwable t) { + LOG.warn("title handler attach failed", t); + return false; + } + } + + private long batteryStart = 0; + private final java.util.List consoleLines = java.util.Collections.synchronizedList(new ArrayList<>()); + + private void installFocusRecorder() { + batteryStart = System.currentTimeMillis(); + KeyboardFocusManager.getCurrentKeyboardFocusManager().addPropertyChangeListener(evt -> { + if (evt.getPropertyName().contains("focusOwner") || evt.getPropertyName().contains("activeWindow")) { + StringWriter stack = new StringWriter(); + new Throwable("focusOwner transition").printStackTrace(new PrintWriter(stack)); + log("FOCUS-STACK " + stack); + log("FOCUS-TIME ts=" + (System.currentTimeMillis() - batteryStart) + + " prop=" + evt.getPropertyName() + + " old=" + compName(evt.getOldValue()) + + " new=" + compName(evt.getNewValue())); + } + }); + log("focus recorder installed"); + } + + private static String compName(Object c) { + return c instanceof Component ? ((Component) c).getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(c)) : String.valueOf(c); + } + + private void mark(String step) { + log("BATTERY-MARK ts=" + (System.currentTimeMillis() - batteryStart) + " step=" + step); + } + + private int realSequence=0; + private final java.util.concurrent.ConcurrentHashMap realReplies=new java.util.concurrent.ConcurrentHashMap<>(); + + // Manual provider-backed battery: real React textarea, real extension tools. + private JsonObject realSnapshot(Object webview, AtomicReference title) throws Exception { + int id=++realSequence; + invoke(webview, "executeJavaScript", "document.title='ZFE2E:' + JSON.stringify((()=>{let e=[...document.querySelectorAll('textarea')].find(x=>x.offsetWidth&&x.offsetHeight);let r=e&&e.getBoundingClientRect();let z=window.__focusE2E||{};let m=z.messages||[];let last=m[m.length-1]||{};return {snapshot:"+id+",input:!!e,value:e?e.value:null,active:e===document.activeElement,disabled:e?e.disabled:null,rect:r?{x:r.x,y:r.y,w:r.width,h:r.height}:null,messages:m.length,events:z.events||0,partial:z.partial||0,lastAsk:last.ask||null,lastTs:last.ts||null,lastText:typeof last.text==='string'?last.text.slice(0,300):null,lastSay:last.say||null,lastPartial:!!last.partial,provider:z.provider||null,body:document.body.innerText.slice(-1200)};})());"); + long deadline=System.currentTimeMillis()+30000; + while(System.currentTimeMillis() { + if (event instanceof KeyEvent) { + KeyEvent k=(KeyEvent)event; + if (k.getKeyCode()==KeyEvent.VK_X || k.getKeyCode()==KeyEvent.VK_Y || k.getKeyChar()=='x' || k.getKeyChar()=='y') + log("E2EAWT PRE ts="+System.currentTimeMillis()+" when="+k.getWhen()+" id="+k.getID()+" key="+k.getKeyCode()+" char="+((int)k.getKeyChar())+" consumed="+k.isConsumed()+" source="+compName(k.getSource())+" owner="+compName(KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner())); + } + return false; + }; + com.intellij.ide.IdeEventQueue.EventDispatcher post = event -> { + if (event instanceof KeyEvent) { + KeyEvent k=(KeyEvent)event; + if (k.getKeyCode()==KeyEvent.VK_X || k.getKeyCode()==KeyEvent.VK_Y || k.getKeyChar()=='x' || k.getKeyChar()=='y') + log("E2EAWT POST ts="+System.currentTimeMillis()+" when="+k.getWhen()+" id="+k.getID()+" key="+k.getKeyCode()+" consumed="+k.isConsumed()+" source="+compName(k.getSource())); + } + return false; + }; + runOnEdt(()->{com.intellij.ide.IdeEventQueue.getInstance().addPreprocessor(pre,project);com.intellij.ide.IdeEventQueue.getInstance().addPostprocessor(post,project);}); + Object manager = project.getService(zooClass("org.zoocode.jetbrains.webview.WebViewManager")); + Object webview = poll(120000, () -> invoke(manager, "getLatestWebView")); + poll(120000, () -> invoke(webview, "isPageLoaded")); + Object browser = field(webview, "browser"); + AtomicReference title = new AtomicReference<>(); + addTitleHandler(browser, invoke(browser, "getCefBrowser"), title); + invoke(webview, "executeJavaScript", "window.__focusE2E={messages:[],events:0,partial:0};window.addEventListener('message',e=>{let d=e.data,z=window.__focusE2E;if(!d)return;z.events++;if(d.type==='state'&&d.state){if(d.state.clineMessages)z.messages=d.state.clineMessages;z.provider=d.state.apiConfiguration&&d.state.apiConfiguration.apiProvider;}if(d.type==='partialMessage'){z.partial++;if(d.partialMessage){let i=z.messages.findIndex(m=>m.ts===d.partialMessage.ts);if(i>=0)z.messages[i]=d.partialMessage;else z.messages.push(d.partialMessage);}}});"); + invoke(webview,"executeJavaScript","window.__focusNodeSeq=0;function e2eNode(e){if(!e)return null;if(!e.__e2eid)e.__e2eid=++window.__focusNodeSeq;return e.tagName+'@'+e.__e2eid;}for(let kind of ['keydown','input','focusin','focusout'])document.addEventListener(kind,e=>{if(kind==='keydown'&&!['x','y'].includes(e.key))return;console.info('E2EKEY:'+JSON.stringify({kind,ts:Date.now(),key:e.key||null,mod:e.ctrlKey||e.altKey||e.metaKey||false,node:e2eNode(e.target),active:e2eNode(document.activeElement),hasFocus:document.hasFocus(),length:typeof e.target.value==='string'?e.target.value.length:null}));},true);for(let kind of ['focus','blur'])window.addEventListener(kind,e=>console.info('E2EWINDOW:'+JSON.stringify({kind,ts:Date.now(),active:e2eNode(document.activeElement),hasFocus:document.hasFocus()})),true);"); + realSend(webview, "{\"type\":\"webviewDidLaunch\"}"); + realSend(webview, "{\"type\":\"mode\",\"text\":\"code\"}"); + Thread.sleep(1500); + String configuration = "{\"type\":\"upsertApiConfiguration\",\"text\":\"focus-solheim\",\"apiConfiguration\":{\"apiProvider\":\"openai\",\"openAiBaseUrl\":\"http://127.0.0.1:19487/v1\",\"openAiApiKey\":\"LOCAL_TOKEN\",\"openAiModelId\":\"qwen3.8-27b\",\"openAiStrictToolSchemas\":false,\"openAiExtraBody\":\"{\\\"chat_template_kwargs\\\":{\\\"enable_thinking\\\":false}}\",\"openAiCustomModelInfo\":{\"contextWindow\":131072,\"maxTokens\":4096,\"supportsImages\":false,\"supportsPromptCache\":false,\"supportsNativeTools\":true,\"inputPrice\":0,\"outputPrice\":0}}}"; + JsonObject config = JsonParser.parseString(configuration).getAsJsonObject(); + config.getAsJsonObject("apiConfiguration").addProperty("openAiApiKey", System.getenv("SOLHEIM_RELAY_TOKEN")); + realSend(webview, config.toString()); + realSend(webview, "{\"type\":\"updateSettings\",\"updatedSettings\":{\"autoApprovalEnabled\":true,\"alwaysAllowReadOnly\":true,\"alwaysAllowWrite\":true,\"alwaysAllowExecute\":true,\"allowedCommands\":[\"printf\",\"sleep\",\"cat\",\"pwd\"],\"terminalShellIntegrationDisabled\":false,\"destructiveCommandGuardEnabled\":false,\"maxRequests\":20,\"telemetrySetting\":\"disabled\"}}"); + realSend(webview, "{\"type\":\"didShowAnnouncement\"}"); + invoke(webview, "executeJavaScript", "[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Close')?.click();"); + JsonObject snap = null; + for (int i=0;i<30;i++) { Thread.sleep(1000); snap=realSnapshot(webview,title); if(snap.get("input").getAsBoolean()&&"openai".equals(snap.get("provider").isJsonNull()?"":snap.get("provider").getAsString()))break; } + record("P_configuredDOM", snap); + if (!snap.get("input").getAsBoolean()) { fail("realInputMissing", "real React textarea was not rendered"); return; } + runOnEdt(() -> ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID).show(null)); + Thread.sleep(1000); + Object ready = poll(180000, () -> { + final boolean[] showing = {false}; + runOnEdt(() -> showing[0] = ((Component) invoke(browser, "getComponent")).isShowing()); + return showing[0] && !com.intellij.openapi.project.DumbService.getInstance(project).isDumb(); + }); + if (!Boolean.TRUE.equals(ready)) throw new IllegalStateException("Chat remained hidden during indexing"); + screenshot("p-configured"); + clickInBrowser(browser, snap.getAsJsonObject("rect")); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents( + new java.awt.datatransfer.StringSelection("Use tools in this throwaway workspace: execute_command printf SOLHEIM_FIRST; sleep 5. Read src/Main.java, then apply_diff replacing throwaway with solheim_e2e. Execute printf SOLHEIM_SECOND; sleep 5; cat src/Main.java. No files outside this workspace. Briefly explain each step and finish with attempt_completion. Do not ask questions."),null); + Robot robot = new Robot(); + robot.keyPress(KeyEvent.VK_CONTROL); robot.keyPress(KeyEvent.VK_V); + robot.keyRelease(KeyEvent.VK_V); robot.keyRelease(KeyEvent.VK_CONTROL); + Thread.sleep(1000); + record("P_promptEntered",realSnapshot(webview,title)); + robot.keyPress(KeyEvent.VK_ENTER); robot.keyRelease(KeyEvent.VK_ENTER); + mark("P-real-conversation-submitted"); + Thread.sleep(2000); + snap = realSnapshot(webview,title); + if (!snap.get("value").isJsonNull() && !snap.get("value").getAsString().isEmpty()) { record("P_afterSubmit",snap); fail("realSubmitFailed","Enter did not submit the real chat input");return; } + if (snap.get("messages").getAsInt()==0) { record("P_afterSubmit",snap); fail("realTaskMissing","No task appeared after submitting prompt");return; } + // Establish a single focus point before tools run; subsequent sampling never refocuses. + clickInBrowser(browser,snap.getAsJsonObject("rect")); + String expected=""; + boolean typingHealthy=true; + boolean completed=false; + int initialMessages=snap.get("messages").getAsInt(); + long deadline=System.currentTimeMillis()+240000; + int sample=0; + while(System.currentTimeMillis()initialMessages; + record("P_realHistoryGrew",historyGrew); + record("P_realTypingSurvived",typingHealthy); + record("P_final",snap); + boolean firstCompleted=completed; + if (completed) { + mark("P-followup-user-turn"); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new java.awt.datatransfer.StringSelection( + "Run printf SOLHEIM_THIRD; sleep 5 in this workspace and finish with attempt_completion. Do not change files."),null); + robot.keyPress(KeyEvent.VK_CONTROL);robot.keyPress(KeyEvent.VK_A);robot.keyRelease(KeyEvent.VK_A);robot.keyRelease(KeyEvent.VK_CONTROL); + robot.keyPress(KeyEvent.VK_CONTROL);robot.keyPress(KeyEvent.VK_V);robot.keyRelease(KeyEvent.VK_V);robot.keyRelease(KeyEvent.VK_CONTROL); + Thread.sleep(800); + robot.keyPress(KeyEvent.VK_ENTER);robot.keyRelease(KeyEvent.VK_ENTER); + Thread.sleep(2000); + expected="";completed=false; + deadline=System.currentTimeMillis()+150000; + int followup=0; + while(System.currentTimeMillis()2) {completed=true;break;} + if (!answerCommandAsk(webview, snap, ask)) return; + } + } + record("P_followupCompleted",completed); + record("P_bothTurnsTypingSurvived",typingHealthy); + boolean fixtureChanged = Files.readString(Paths.get(project.getBasePath(),"src/Main.java")).contains("solheim_e2e"); + record("P_fixtureChanged",fixtureChanged); + screenshot("p-real-conversation"); + finish("testP-real-solheim-conversation", firstCompleted&&completed&&typingHealthy&&historyGrew&&fixtureChanged?"PASS":"FAIL-REAL-CONVERSATION"); + } + + private void clickInBrowser(Object jbCefBrowser, JsonObject rect) throws Exception { + final Point[] loc = new Point[1]; + runOnEdt(() -> { + try { + Component comp = (Component) invoke(jbCefBrowser, "getComponent"); + for (Component parent = comp; parent != null; parent = parent.getParent()) + log("browser hierarchy " + compName(parent) + " visible=" + parent.isVisible() + " showing=" + parent.isShowing()); + loc[0] = comp.getLocationOnScreen(); + } catch (Throwable t) { + log("browser coordinate failure: " + t); + loc[0] = null; + } + }); + if (loc[0] == null) { + throw new IllegalStateException("browser component not showing"); + } + int cx = loc[0].x + rect.get("x").getAsInt() + rect.get("w").getAsInt() / 2; + int cy = loc[0].y + rect.get("y").getAsInt() + rect.get("h").getAsInt() / 2; + Robot robot = new Robot(); + robot.setAutoDelay(60); + robot.mouseMove(cx, cy); + robot.mousePress(InputEvent.BUTTON1_DOWN_MASK); + robot.mouseRelease(InputEvent.BUTTON1_DOWN_MASK); + log("clicked browser at " + cx + "," + cy); + } + + private void typeText(String text) throws Exception { + Robot robot = new Robot(); + robot.setAutoDelay(90); + for (char c : text.toCharArray()) { + int code = KeyEvent.getExtendedKeyCodeForChar(c); + if (code == KeyEvent.VK_UNDEFINED) { + continue; + } + robot.keyPress(code); + robot.keyRelease(code); + } + log("typed text of length " + text.length()); + } + + private String swingFocusOwnerClass() { + final String[] r = {null}; + runOnEdt(() -> { + Component o = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); + r[0] = o == null ? "null" : o.getClass().getName(); + }); + return r[0]; + } + + /** Exact component identity of the Swing focus owner, not just its class. */ + private String swingFocusOwnerIdentity() { + final String[] r = {null}; + runOnEdt(() -> { + Component o = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); + r[0] = o == null ? "null" : o.getClass().getName() + "@" + System.identityHashCode(o); + }); + return r[0]; + } + + // ---- Tests B and C: JCEF unusable, fallback expected -------------------- + + private void dismissStartupDialogs(int seconds) { + long deadline = System.currentTimeMillis() + seconds * 1000L; + while (System.currentTimeMillis() < deadline) { + runOnEdt(() -> { + for (Window w : Window.getWindows()) { + if (!(w instanceof Dialog) || !w.isShowing() || (w instanceof JFrame)) { + continue; + } + handleDialog((Dialog) w); + } + }); + try { + Thread.sleep(2_000); + } catch (InterruptedException e) { + return; + } + } + } + + private void handleDialog(Dialog d) { + List buttons = new ArrayList<>(); + List boxes = new ArrayList<>(); + collect(d, buttons, boxes); + for (JCheckBox box : boxes) { + String t = (box.getText() == null ? "" : box.getText()).toLowerCase(); + if ((t.contains("accept") || t.contains("agree")) && !box.isSelected()) { + box.setSelected(true); + log("dialog checkbox selected: " + box.getText()); + } + } + String[] prefer = {"accept", "agree", "continue", "don't send", "dont send", "start", "skip", "no thanks", "got it", "close", "ok"}; + for (String p : prefer) { + for (JButton b : buttons) { + String t = (b.getText() == null ? "" : b.getText()).toLowerCase(); + if (t.contains(p) && b.isEnabled() && b.isShowing()) { + log("dialog button clicked: " + b.getText() + " (window: " + d.getName() + ")"); + b.doClick(); + return; + } + } + } + } + + private void collect(Component c, List buttons, List boxes) { + if (c instanceof JButton) { + buttons.add((JButton) c); + } + if (c instanceof JCheckBox) { + boxes.add((JCheckBox) c); + } + if (c instanceof Container) { + for (Component child : ((Container) c).getComponents()) { + collect(child, buttons, boxes); + } + } + } + + // ---- Utilities ---------------------------------------------------------- + + private String reflectJcefSupport() { + try { + Class cls = zooClass("org.zoocode.jetbrains.util.JcefSupport"); + Object companion = cls.getField("Companion").get(null); + Object state = companion.getClass().getMethod("current").invoke(companion); + return ((Enum) state).name(); + } catch (Throwable t) { + return "PROBE-FAILED: " + t; + } + } + + private Object invoke(Object target, String name) { + try { + for (Method m : target.getClass().getMethods()) { + if (m.getName().equals(name) && m.getParameterCount() == 0) { + return m.invoke(target); + } + } + } catch (Throwable t) { + throw new RuntimeException("invoke " + name + " failed", t); + } + throw new RuntimeException("method not found: " + name); + } + + private Object invoke(Object target, String name, Object... args) { + try { + for (Method m : target.getClass().getMethods()) { + if (m.getName().equals(name) && m.getParameterCount() == args.length) { + return m.invoke(target, args); + } + } + } catch (java.lang.reflect.InvocationTargetException t) { + Throwable cause = t.getCause() != null ? t.getCause() : t; + throw new RuntimeException("invoke " + name + "/" + args.length + " failed: " + + cause.getClass().getName() + ": " + cause.getMessage(), cause); + } catch (Throwable t) { + throw new RuntimeException("invoke " + name + "/" + args.length + " failed: " + t.getClass().getName() + + ": " + t.getMessage(), t); + } + throw new RuntimeException("method not found: " + name + "/" + args.length); + } + + private Object field(Object target, String name) { + try { + Class c = target.getClass(); + while (c != null) { + try { + java.lang.reflect.Field f = c.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } catch (NoSuchFieldException e) { + c = c.getSuperclass(); + } + } + } catch (Throwable t) { + throw new RuntimeException("field " + name + " failed", t); + } + throw new RuntimeException("field not found: " + name); + } + + private interface Poll { + Object get(); + } + + private Object poll(long timeoutMs, Poll p) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + Object v = p.get(); + if (v != null && !(v instanceof Boolean) || v instanceof Boolean && (Boolean) v) { + return v; + } + Thread.sleep(1_000); + } + return p.get(); + } + + private void runOnEdt(Runnable action) { + if (SwingUtilities.isEventDispatchThread()) { action.run(); return; } + AtomicReference failure = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + SwingUtilities.invokeLater(() -> { + try { action.run(); } catch (Throwable error) { failure.set(error); } + finally { latch.countDown(); } + }); + try { + if (!latch.await(30, TimeUnit.SECONDS)) throw new IllegalStateException("EDT timeout"); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(error); + } + if (failure.get() != null) throw new IllegalStateException("EDT action failed", failure.get()); + } + + private void waitEdt() throws InterruptedException { + runOnEdt(() -> { + }); + } + + private void screenshot(String name) { + try { + waitEdt(); + Robot robot = new Robot(); + Rectangle screen = new Rectangle(Toolkit.getDefaultToolkit().getScreenSize()); + BufferedImage img = robot.createScreenCapture(screen); + File out = evidenceDir.resolve(name + ".png").toFile(); + ImageIO.write(img, "png", out); + log("screenshot saved: " + out.getAbsolutePath()); + } catch (Throwable t) { + record("screenshotError:" + name, String.valueOf(t)); + } + } + + private void record(String key, Object value) { + synchronized (evidenceLock) { + if (value != null) { + checks.add(key + " = " + value); + } else { + checks.add(key + " = null"); + } + log("RECORD " + key + " = " + value); + } + } + + /** + * Approves one command ask when its text is a fixture command. Any other command fails the run, + * because the profile runs as the developer. Output asks carry no command and need no check. + * @return false when the run failed and the caller must stop + */ + private boolean answerCommandAsk(Object webview, JsonObject snap, String ask) throws Exception { + if (snap.get("lastPartial").getAsBoolean()) return true; + if ("command".equals(ask)) { + JsonElement text = snap.get("lastText"); + JsonElement tsValue = snap.get("lastTs"); + String command = text == null || text.isJsonNull() ? "" : text.getAsString().trim(); + if (!FIXTURE_COMMAND.matcher(command).matches()) { + fail("unexpectedCommand", "Refused a command outside the fixture: " + command); + return false; + } + long ts = tsValue == null || tsValue.isJsonNull() ? -1 : tsValue.getAsLong(); + if (ts >= 0 && ts == lastApprovedTs) return true; + lastApprovedTs = ts; + } else if (!"command_output".equals(ask)) { + return true; + } + realSend(webview, "{\"type\":\"askResponse\",\"askResponse\":\"yesButtonClicked\"}"); + return true; + } + + private void fail(String reason, String detail) { + log("FAIL reason=" + reason + " detail=" + detail); + record("failReason", reason + ": " + detail); + finish("test", "FAIL"); + } + + private void finish(String label, String status) { + synchronized (evidenceLock) { + StringBuilder sb = new StringBuilder(); + sb.append("{\n \"label\": \"").append(label).append("\",\n"); + sb.append(" \"status\": \"").append(status).append("\",\n"); + sb.append(" \"mode\": \"").append(System.getProperty("zoo.harness.mode", "A")).append("\",\n"); + sb.append(" \"checks\": {\n"); + for (int i = 0; i < checks.size(); i++) { + String kv = checks.get(i); + int eq = kv.indexOf(" = "); + String k = kv.substring(0, eq).replace("\"", "'"); + String v = kv.substring(eq + 3).replace("\\", "\\\\").replace("\"", "\\\""); + sb.append(" \"").append(k).append("\": \"").append(v).append("\""); + sb.append(i < checks.size() - 1 ? ",\n" : "\n"); + } + sb.append(" }\n}\n"); + try { + Files.write(evidenceDir.resolve("evidence.json"), sb.toString().getBytes(StandardCharsets.UTF_8)); + Files.write(evidenceDir.resolve("status.txt"), status.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + LOG.error("harness driver: cannot write evidence", e); + } + } + log("driver finished with " + status); + } + + private void log(String line) { + String msg = "[zoo-harness-driver] " + line; + LOG.info(msg); + if (progressLog != null) { + try { + synchronized (this) { + Files.write(progressLog, + (msg + "\n").getBytes(StandardCharsets.UTF_8), + java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); + } + } catch (Exception ignored) { + } + } + } +} diff --git a/jetbrains_plugin/scripts/ui-harness/KeyDropProbe.java b/jetbrains_plugin/scripts/ui-harness/KeyDropProbe.java new file mode 100644 index 0000000..b8fb336 --- /dev/null +++ b/jetbrains_plugin/scripts/ui-harness/KeyDropProbe.java @@ -0,0 +1,199 @@ +import java.awt.Component; +import java.awt.GraphicsEnvironment; +import java.awt.KeyboardFocusManager; +import java.awt.Point; +import java.awt.Robot; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.lang.reflect.InvocationTargetException; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import javax.swing.JFrame; +import javax.swing.JTextArea; +import javax.swing.SwingUtilities; + +/** + * Manual, JDK-only diagnostic for keys whose release reaches the application + * without a recorded press. A Robot types keys into a Swing text area. A + * KeyEventDispatcher counts KEY_PRESSED, KEY_TYPED, and KEY_RELEASED + * deliveries. A KeyboardFocusManager listener counts focus-owner changes. + * + * Usage: java KeyDropProbe.java [control|churn] [keys] [churnEvery] [style] + * Defaults: control, 120 keys, churn every 7 keys, style hidden-frame. + * Styles (churn mode only): hidden-frame | visible-frame | self-refocus + * | dispose-without-return. + * + * Needs a display and a headful JDK or JRE. Run it under a desktop session or + * Xvfb. A headless runtime exits with HeadlessException. + * + * This probe is analogous evidence only. It contains no IDE, plugin, or JCEF + * code, and its churn styles move the focus owner while the recorded IDE loss + * kept the owner stable. A loss here does not identify the initiating IDE + * platform call, does not prove a JDK root cause, and does not clear the + * product defect described in docs/validation/chat-focus-preservation.md. + */ +public class KeyDropProbe { + private static final Set STYLES = Set.of( + "hidden-frame", "visible-frame", "self-refocus", "dispose-without-return"); + private static final AtomicInteger pressed = new AtomicInteger(); + private static final AtomicInteger typed = new AtomicInteger(); + private static final AtomicInteger released = new AtomicInteger(); + private static volatile String ownerDesc = "none"; + private static int ownerChanges = 0; + + public static void main(String[] args) throws Exception { + String mode = arg(args, 0, "control").toLowerCase(Locale.ROOT); + int keys = Integer.parseInt(arg(args, 1, "120")); + int churnEvery = Integer.parseInt(arg(args, 2, "7")); + String style = arg(args, 3, "hidden-frame").toLowerCase(Locale.ROOT); + if (!mode.equals("control") && !mode.equals("churn")) { + System.err.println("mode must be control or churn, got: " + mode); + System.exit(2); + } + if (mode.equals("churn") && !STYLES.contains(style)) { + System.err.println("churn style must be one of " + STYLES + ", got: " + style); + System.exit(2); + } + if (keys < 1 || churnEvery < 1) { + System.err.println("keys and churnEvery must be at least 1"); + System.exit(2); + } + if (GraphicsEnvironment.isHeadless()) { + System.err.println("No display or headless runtime: run under a desktop session or Xvfb with a headful JDK."); + System.exit(2); + } + + KeyboardFocusManager kfm = KeyboardFocusManager.getCurrentKeyboardFocusManager(); + kfm.addKeyEventDispatcher(KeyDropProbe::countDelivery); + kfm.addPropertyChangeListener(event -> { + if ("focusOwner".equals(event.getPropertyName())) { + ownerChanges++; + Object newOwner = event.getNewValue(); + ownerDesc = newOwner == null ? "null" : newOwner.getClass().getSimpleName(); + System.out.println("[owner-change #" + ownerChanges + "] new=" + ownerDesc); + } + }); + + JFrame[] mainFrame = new JFrame[1]; + JTextArea[] mainArea = new JTextArea[1]; + SwingUtilities.invokeAndWait(() -> { + mainFrame[0] = new JFrame("main"); + mainArea[0] = new JTextArea(); + mainFrame[0].add(mainArea[0]); + mainFrame[0].setSize(420, 300); + mainFrame[0].setLocation(10, 10); + mainFrame[0].setVisible(true); + }); + Robot robot = new Robot(); + robot.setAutoDelay(30); + Point areaOrigin = mainArea[0].getLocationOnScreen(); + robot.mouseMove(areaOrigin.x + 40, areaOrigin.y + 15); + robot.mousePress(InputEvent.BUTTON1_DOWN_MASK); + robot.mouseRelease(InputEvent.BUTTON1_DOWN_MASK); + Thread.sleep(400); + Component startOwner = kfm.getFocusOwner(); + String shape = mode.equals("churn") ? " mode=churn style=" + style : " mode=control"; + System.out.println("run:" + shape + " keys=" + keys + " churnEvery=" + churnEvery + + " startOwner=" + (startOwner == null ? "null" : startOwner.getClass().getName())); + + Thread churner = new Thread(() -> churnLoop(mode, style, keys, churnEvery, mainArea[0])); + churner.start(); + + int lostKeys = 0; + for (int key = 0; key < keys; key++) { + int pressBefore = pressed.get(); + int typedBefore = typed.get(); + int releaseBefore = released.get(); + robot.keyPress(KeyEvent.VK_X); + robot.keyRelease(KeyEvent.VK_X); + Thread.sleep(65); + int pressDelta = pressed.get() - pressBefore; + int typedDelta = typed.get() - typedBefore; + int releaseDelta = released.get() - releaseBefore; + if (releaseDelta >= 1 && pressDelta == 0) { + lostKeys++; + System.out.println("[LOSS] key " + key + ": release delivered, press missing." + + " typedDelta=" + typedDelta + + " focusOwner=" + ownerDesc + + " ownerChangesSoFar=" + ownerChanges); + } else if (pressDelta >= 1 && releaseDelta == 0) { + System.out.println("[ODD] key " + key + ": press without release."); + } else if (pressDelta >= 1 && releaseDelta >= 1 && typedDelta == 0) { + System.out.println("[ODD] key " + key + ": press and release without typed."); + } + } + churner.join(); + System.out.println("RESULT" + shape + " lostReleaseWithoutPress=" + lostKeys + "/" + keys + + " press=" + pressed.get() + " typed=" + typed.get() + " released=" + released.get() + + " ownerChanges=" + ownerChanges + " finalOwner=" + ownerDesc); + System.exit(0); + } + + private static boolean countDelivery(KeyEvent event) { + switch (event.getID()) { + case KeyEvent.KEY_PRESSED -> pressed.incrementAndGet(); + case KeyEvent.KEY_TYPED -> typed.incrementAndGet(); + case KeyEvent.KEY_RELEASED -> released.incrementAndGet(); + } + return false; + } + + private static void churnLoop(String mode, String style, int keys, int churnEvery, JTextArea mainArea) { + if (mode.equals("control")) { + return; + } + for (int i = 0; i < keys / churnEvery + 1; i++) { + try { + Thread.sleep(churnEvery * 95L); + final int round = i; + SwingUtilities.invokeAndWait(() -> churnOnce(style, mainArea, round)); + } catch (InterruptedException | InvocationTargetException stopped) { + System.err.println("[CHURN-STOPPED] round " + i + ": " + stopped); + return; + } + } + } + + private static void churnOnce(String style, JTextArea mainArea, int round) { + switch (style) { + case "hidden-frame" -> { + JFrame side = newSideFrame(round); + sideTextArea(side).requestFocus(); + side.dispose(); + } + case "visible-frame" -> { + JFrame side = newSideFrame(round); + side.setVisible(true); + sideTextArea(side).requestFocus(); + mainArea.requestFocus(); + side.dispose(); + } + case "self-refocus" -> mainArea.requestFocusInWindow(); + case "dispose-without-return" -> { + JFrame side = newSideFrame(round); + side.setVisible(true); + sideTextArea(side).requestFocus(); + side.dispose(); + mainArea.requestFocusInWindow(); + } + default -> throw new IllegalArgumentException("unknown churn style: " + style); + } + } + + private static JFrame newSideFrame(int round) { + JFrame side = new JFrame("probe" + round); + side.add(new JTextArea()); + side.setSize(200, 120); + side.setLocation(500, 60); + return side; + } + + private static JTextArea sideTextArea(JFrame side) { + return (JTextArea) side.getContentPane().getComponent(0); + } + + private static String arg(String[] args, int index, String fallback) { + return args.length > index ? args[index] : fallback; + } +} diff --git a/jetbrains_plugin/scripts/ui-harness/README.md b/jetbrains_plugin/scripts/ui-harness/README.md new file mode 100644 index 0000000..88b9779 --- /dev/null +++ b/jetbrains_plugin/scripts/ui-harness/README.md @@ -0,0 +1,94 @@ +# Manual Solheim focus smoke test + +The source and runner in this directory work without the ignored investigation +fixture. Prerequisites: a JCEF-enabled IntelliJ installation with JBR `javac` +(tested with IU-253.28294.334), `jar`, `unzip`, Xvfb, xdotool, and Openbox. +Build the ZooCode plugin distribution first. Its Kotlin plugin version is +3.78.0; the embedded Zoo Code extension version is 3.84.0. + +Store the Solheim key in a private file outside the repository (mode 0600). +Generate a random per-run local token. Run the `export` line in both shells, with the same value: + +```sh +export SOLHEIM_RELAY_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" +SOLHEIM_KEY_FILE=/path/to/private-key-file \ +SOLHEIM_PROXY_LOG=/path/to/metadata.jsonl python3 solheim_proxy.py +``` + +The relay binds only 127.0.0.1:19487 and requires the token as a bearer key. +It rejects Origin headers and unexpected Host headers. Invalid request framing +and malformed JSON receive 400; bodies larger than 8 MiB receive 413. The extension receives +this local token; the upstream key stays outside IDE settings. The relay sends +real requests to https://api.solheim.ai/v1 and logs only metadata and tool names. + +In a second shell, export the same `SOLHEIM_RELAY_TOKEN` value. Then run from this directory: + +```sh +IDE_DIR=/path/to/intellij \ +PLUGIN_ZIP=/path/to/ZooCode-3.78.0.zip \ +HARNESS_WM_BIN=/usr/bin/openbox \ +HARNESS_HEAP_MB=768 HARNESS_CPU_COUNT=4 DEADLINE_SECONDS=650 \ +bash run-solheim.sh +``` + +The runner compiles `HarnessDriver.java`, installs it and the supplied plugin +zip in a fresh temporary profile, starts Xvfb and the native IDEA launcher, +and prints the evidence directory. It requires PASS for a zero exit status. +An Openbox wrapper may set library/config paths and must accept `--sm-disable`. +The native launcher avoids IDEA's script-launcher warning covering the textarea. + +The driver configures `qwen3.8-27b`, submits prompts through the real React +textarea using Robot/clipboard, and types drafts during two model turns. +The model runs commands, reads a file, and applies a diff in the throwaway +workspace. Snapshot replies are correlated by request ID. PASS requires both +turns completed, exact draft preservation, history growth, and a changed fixture. +No fake input, fake history, or scripted model responses are used. + +The profile auto-approves workspace reads and writes. The driver answers a command +request only when the command is one of the three fixture commands, for example +`printf SOLHEIM_FIRST; sleep 5`. Any other command fails the run with +`unexpectedCommand`. The shell still runs as your user, so keep the profile +separate from a real project. +Stop the relay, unset the token, and remove the private key file afterward. +The disposable profile contains the now-expired local token; remove it when +finished with the evidence. Check traces as well as status: a pass does not +clear the intermittent first-editor key-delivery gap recorded in +[the validation report](../../../docs/validation/chat-focus-preservation.md). + +Relay access tests, without provider calls: + +```sh +python3 -m unittest test_solheim_proxy.py +``` + +## JDK key-delivery probe + +`KeyDropProbe.java` is a standalone manual diagnostic. It has no dependency +beyond a headful JDK. A `java.awt.Robot` types keys into a Swing text area. A +`KeyEventDispatcher` counts `KEY_PRESSED`, `KEY_TYPED`, and `KEY_RELEASED` +deliveries. The probe reports keys whose release arrives without a press, odd +press/typed/release combinations, and every focus-owner change. + +Run it under a desktop session or Xvfb. A headless JRE package lacks the AWT +natives and exits with `HeadlessException`; use a JDK that contains +`libawt_xawt.so`: + +```sh +xvfb-run -a /path/to/headful-jdk/bin/java KeyDropProbe.java control 120 7 +xvfb-run -a /path/to/headful-jdk/bin/java KeyDropProbe.java churn 120 7 hidden-frame +``` + +Arguments: mode (`control` or `churn`), key count, churn interval in keys, and +for churn mode one style: `hidden-frame`, `visible-frame`, `self-refocus`, or +`dispose-without-return`. The `RESULT` line prints the delivery counters, the +release-without-press count, and the focus-owner change count. + +Limitations. The probe contains no IDE, plugin, or JCEF code. Its churn styles +move the focus owner, while the recorded IDE loss kept the owner stable. A loss +here is analogous evidence for the release-without-press signature only. It +does not identify the initiating IDE platform call, does not prove a JDK root +cause, and does not clear the intermittent draft loss described in +[the validation report](../../../docs/validation/chat-focus-preservation.md). +Results vary with window manager, keyboard layout, and timing. Compare control +and churn modes on the same host in the same session; a single run decides +nothing. diff --git a/jetbrains_plugin/scripts/ui-harness/plugin.xml b/jetbrains_plugin/scripts/ui-harness/plugin.xml new file mode 100644 index 0000000..3c7a02f --- /dev/null +++ b/jetbrains_plugin/scripts/ui-harness/plugin.xml @@ -0,0 +1,13 @@ + + + org.zoocode.harness.driver + Zoo Harness Driver (TEST ONLY) + zoo-code-harness + com.intellij.modules.platform + + + + diff --git a/jetbrains_plugin/scripts/ui-harness/run-solheim.sh b/jetbrains_plugin/scripts/ui-harness/run-solheim.sh new file mode 100644 index 0000000..3997fc9 --- /dev/null +++ b/jetbrains_plugin/scripts/ui-harness/run-solheim.sh @@ -0,0 +1,133 @@ +set -uo pipefail +SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)" +: "${IDE_DIR:?Set IDE_DIR to a JCEF-enabled IntelliJ installation}" +: "${PLUGIN_ZIP:?Set PLUGIN_ZIP to the built ZooCode distribution}" +: "${SOLHEIM_RELAY_TOKEN:?Set the same random token as the relay}" +: "${HARNESS_WM_BIN:?Set HARNESS_WM_BIN to openbox or an equivalent wrapper}" +IDE_DIR="$(realpath "$IDE_DIR")" +PLUGIN_ZIP="$(realpath "$PLUGIN_ZIP")" +TEST=p +H="$(mktemp -d "${TMPDIR:-/tmp}/zoo-solheim-e2e.XXXXXXXX")" +echo "Disposable harness and evidence: $H" +mkdir -p "$H/driver/classes/META-INF" "$H/plugins-dist/harness-driver/lib" +"$IDE_DIR/jbr/bin/javac" -cp "$IDE_DIR/lib/*" -d "$H/driver/classes" "$SOURCE_DIR/HarnessDriver.java" || exit 1 +cp "$SOURCE_DIR/plugin.xml" "$H/driver/classes/META-INF/plugin.xml" +jar cf "$H/plugins-dist/harness-driver/lib/driver.jar" -C "$H/driver/classes" . || exit 1 +unzip -q "$PLUGIN_ZIP" -d "$H/plugins-dist" || exit 1 +CFG="$H/cfg/$TEST" +EV="$H/evidence/$TEST" +DEADLINE="${DEADLINE_SECONDS:-780}" + +rm -rf "$CFG" "$EV" +mkdir -p "$CFG/config" "$CFG/system" "$CFG/log" "$CFG/plugins" "$EV" "$H/props" + +XVFB_NUM=$((300 + RANDOM % 500)) +Xvfb ":$XVFB_NUM" -screen 0 1600x1000x24 -nolisten tcp > "$EV/xvfb.log" 2>&1 & +XVFB_PID=$! +sleep 2 +cleanup() { + if [ -n "${CLICKER_PID:-}" ]; then kill "$CLICKER_PID" 2>/dev/null; fi + if [ -n "${WM_PID:-}" ]; then kill "$WM_PID" 2>/dev/null; fi + if [ -n "${XVFB_PID:-}" ] && kill -0 "$XVFB_PID" 2>/dev/null; then + kill -TERM "$XVFB_PID" 2>/dev/null + fi + if [ -n "${IDE_PID:-}" ] && kill -0 "$IDE_PID" 2>/dev/null; then + pkill -TERM -P "$IDE_PID" 2>/dev/null + kill -TERM "$IDE_PID" 2>/dev/null + sleep 3 + kill -9 "$IDE_PID" 2>/dev/null + pkill -9 -P "$IDE_PID" 2>/dev/null + fi +} +trap cleanup EXIT + +export DISPLAY=":$XVFB_NUM" +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/xdg-$XVFB_NUM}" +mkdir -p "$XDG_RUNTIME_DIR" +chmod 700 "$XDG_RUNTIME_DIR" +if [ -n "${HARNESS_WM_BIN:-}" ]; then + "$HARNESS_WM_BIN" --sm-disable > "$EV/window-manager.log" 2>&1 & + WM_PID=$! +fi + +cat > "$H/props/idea-$TEST.properties" < "$H/props/vm-$TEST.vmoptions" + +cp -r "$H/plugins-dist/ZooCode" "$CFG/plugins/ZooCode" +cp -r "$H/plugins-dist/harness-driver" "$CFG/plugins/harness-driver" + +mkdir -p "$H/workspace/throwaway-project/src" +if [ ! -f "$H/workspace/throwaway-project/src/Main.java" ]; then + printf 'public class Main { public static void main(String[] a) { System.out.println("throwaway"); } }\n' \ + > "$H/workspace/throwaway-project/src/Main.java" +fi + +export IDEA_PROPERTIES="$H/props/idea-$TEST.properties" +export IDEA_VM_OPTIONS="$H/props/vm-$TEST.vmoptions" + +click_startup_dialogs() { + # The user-agreement dialog is borderless, ~600x460, centered on the + # 1600x1000 Xvfb screen. It blocks frame creation before any in-IDE listener + # runs, so dismiss it externally: select the confirm checkbox, then Continue. + for _ in $(seq 1 30); do + for wid in $(xdotool search --onlyvisible . 2>/dev/null); do + geom=$(xdotool getwindowgeometry --shell "$wid" 2>/dev/null) || continue + eval "$geom" + if [ "${WIDTH:-0}" -ge 550 ] && [ "${WIDTH:-0}" -le 700 ] && [ "${HEIGHT:-0}" -ge 400 ] && [ "${HEIGHT:-0}" -le 520 ]; then + echo "harness: clicking startup agreement dialog wid=$wid ${WIDTH}x${HEIGHT}+${X}+${Y}" + xdotool mousemove $((X + 40)) $((Y + HEIGHT * 85 / 100)) click 1 + sleep 1 + xdotool mousemove $((X + WIDTH * 89 / 100)) $((Y + HEIGHT * 93 / 100)) click 1 + sleep 2 + fi + done + sleep 3 + done +} +click_startup_dialogs & +CLICKER_PID=$! + +( + cd "$IDE_DIR/bin" + exec ./idea "$H/workspace/throwaway-project" +) > "$EV/launcher-stdout.log" 2>&1 & +IDE_PID=$! + +STATUS="" +for i in $(seq 1 "$DEADLINE"); do + if [ -f "$EV/status.txt" ]; then + STATUS="$(cat "$EV/status.txt")" + break + fi + if ! kill -0 "$IDE_PID" 2>/dev/null; then + STATUS="CRASHED" + break + fi + sleep 1 +done +[ -z "$STATUS" ] && STATUS="TIMEOUT" + +cleanup +trap - EXIT + +echo "TEST-$TEST result: $STATUS (after ${i}s)" +cp "$CFG/log/idea.log" "$EV/idea.log" 2>/dev/null || true +if [ -d "$CFG/system/log" ]; then + cp -r "$CFG/system/log" "$EV/system-log" 2>/dev/null || true +fi +[ "$STATUS" = PASS ] diff --git a/jetbrains_plugin/scripts/ui-harness/solheim_proxy.py b/jetbrains_plugin/scripts/ui-harness/solheim_proxy.py new file mode 100644 index 0000000..fed0bf6 --- /dev/null +++ b/jetbrains_plugin/scripts/ui-harness/solheim_proxy.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Loopback authentication relay for manual Solheim extension focus smoke tests. + +Real credentials stay in SOLHEIM_KEY_FILE (mode 0600), outside IDE settings/logs. +The extension uses a per-run relay token and http://127.0.0.1:19487/v1. Logs contain only +request metadata, upstream status, stream byte counts, and tool names. +""" +import http.server +import hmac +import json +import os +import time +import urllib.error +import urllib.request +from pathlib import Path + +KEY = None +TOKEN = None +BASE = 'https://api.solheim.ai/v1' +MAX_BODY_BYTES = 8 * 1024 * 1024 +LOG = Path(os.environ.get('SOLHEIM_PROXY_LOG', '/tmp/zoo-solheim-proxy.jsonl')) + + +def record(**fields): + with LOG.open('a') as output: + output.write(json.dumps({'time': time.time(), **fields}) + '\n') + + +class Relay(http.server.BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + + def log_message(self, *_): + pass + + def do_GET(self): + self.relay() + + def do_POST(self): + self.relay() + + def relay(self): + expected_host = '127.0.0.1:' + str(self.server.server_port) + if self.headers.get('Host') != expected_host or self.headers.get('Origin') is not None: + self.send_error(403) + return + supplied = self.headers.get('Authorization', '') + if not TOKEN or not hmac.compare_digest(supplied, 'Bearer ' + TOKEN): + self.send_error(401) + return + if not self.path.startswith('/v1/'): + self.send_error(404) + return + lengths = self.headers.get_all('Content-Length', []) + length_text = lengths[0] if lengths else '0' + if (len(lengths) > 1 or self.headers.get('Transfer-Encoding') is not None + or not length_text.isascii() or not length_text.isdecimal()): + self.send_error(400, 'Invalid request framing') + return + # Check before int conversion, including absurdly long decimal strings. + if len(length_text.lstrip('0')) > 8 or int(length_text.lstrip('0') or '0') > MAX_BODY_BYTES: + self.send_error(413, 'Request body too large') + return + length = int(length_text.lstrip('0') or '0') + body = self.rfile.read(length) + try: + parsed = json.loads(body) if body else {} + if len(body) != length or not isinstance(parsed, dict): + raise ValueError('Expected a JSON object') + if not isinstance(parsed.get('messages', []), list): + raise ValueError('Expected a message list') + except (ValueError, UnicodeError): + self.send_error(400, 'Invalid JSON request body') + return + record(event='request', path=self.path, model=parsed.get('model'), + messages=len(parsed.get('messages', [])), stream=parsed.get('stream', False)) + request = urllib.request.Request(BASE + self.path[3:], data=body or None, + method=self.command, + headers={'Authorization': 'Bearer ' + KEY, + 'Content-Type': 'application/json'}) + try: + response = urllib.request.urlopen(request, timeout=180) + except urllib.error.HTTPError as error: + response = error + except Exception as error: + record(event='transport-error', error=type(error).__name__) + self.send_error(502) + return + self.send_response(response.status) + self.send_header('Content-Type', response.headers.get('Content-Type', 'application/json')) + self.send_header('Connection', 'close') + self.end_headers() + count = 0 + buffer = b'' + tools = set() + try: + while True: + chunk = response.read1(4096) + if not chunk: + break + count += len(chunk) + self.wfile.write(chunk) + self.wfile.flush() + buffer += chunk + while b'\n' in buffer: + line, buffer = buffer.split(b'\n', 1) + if line.startswith(b'data: ') and line != b'data: [DONE]': + try: + event = json.loads(line[6:]) + for choice in event.get('choices', []): + for tool in choice.get('delta', {}).get('tool_calls', []): + name = tool.get('function', {}).get('name') + if name: + tools.add(name) + except (ValueError, TypeError): + pass + except (BrokenPipeError, ConnectionResetError): + record(event='client-disconnected') + finally: + response.close() + self.close_connection = True + record(event='response', status=response.status, bytes=count, tools=sorted(tools)) + + +if __name__ == '__main__': + KEY = Path(os.environ['SOLHEIM_KEY_FILE']).read_text().strip() + TOKEN = os.environ['SOLHEIM_RELAY_TOKEN'] + if len(TOKEN) < 32: + raise SystemExit('Use a random relay token of at least 32 characters') + http.server.ThreadingHTTPServer(('127.0.0.1', 19487), Relay).serve_forever() diff --git a/jetbrains_plugin/scripts/ui-harness/test_solheim_proxy.py b/jetbrains_plugin/scripts/ui-harness/test_solheim_proxy.py new file mode 100644 index 0000000..64f1113 --- /dev/null +++ b/jetbrains_plugin/scripts/ui-harness/test_solheim_proxy.py @@ -0,0 +1,109 @@ +"""Local relay access checks; never contact the provider.""" +import http.client +import http.server +import threading +import unittest +from unittest.mock import patch +import solheim_proxy as relay + + +class RelayAccessTest(unittest.TestCase): + def setUp(self): + relay.TOKEN = 'a' * 64 + self.server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), relay.Relay) + self.worker = threading.Thread(target=self.server.serve_forever) + self.worker.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.worker.join() + + def request(self, headers, body=None, method='GET'): + connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port) + with patch.object(relay.urllib.request, 'urlopen') as upstream: + connection.request(method, '/v1/models', body=body, headers=headers) + response = connection.getresponse() + response.read() + connection.close() + upstream.assert_not_called() + return response.status + + def test_missing_token(self): + self.assertEqual(401, self.request({})) + + def test_wrong_token(self): + self.assertEqual(401, self.request({'Authorization': 'Bearer wrong'})) + + def test_browser_origin(self): + self.assertEqual(403, self.request({'Authorization': 'Bearer ' + relay.TOKEN, + 'Origin': 'https://example.com'})) + + def test_untrusted_host(self): + self.assertEqual(403, self.request({'Authorization': 'Bearer ' + relay.TOKEN, + 'Host': 'example.com'})) + + def test_invalid_content_length(self): + for length in ['-1', 'not-a-number', '1.5']: + with self.subTest(length=length): + self.assertEqual(400, self.request({'Authorization': 'Bearer ' + relay.TOKEN, + 'Content-Length': length}, method='POST')) + + def test_excessive_content_length(self): + for length in [str(relay.MAX_BODY_BYTES + 1), '9' * 5000]: + with self.subTest(length=length[:20]): + self.assertEqual(413, self.request({'Authorization': 'Bearer ' + relay.TOKEN, + 'Content-Length': length}, method='POST')) + + def test_malformed_json(self): + for body in [b'{', b'\xff']: + with self.subTest(body=body): + self.assertEqual(400, self.request({'Authorization': 'Bearer ' + relay.TOKEN}, + body=body, method='POST')) + + def test_non_object_json(self): + for body in [b'[]', b'null', b'1']: + with self.subTest(body=body): + self.assertEqual(400, self.request({'Authorization': 'Bearer ' + relay.TOKEN}, + body=body, method='POST')) + + def test_wrong_message_shape(self): + self.assertEqual(400, self.request({'Authorization': 'Bearer ' + relay.TOKEN}, + body=b'{"messages":null}', method='POST')) + + def test_unsupported_transfer_encoding(self): + self.assertEqual(400, self.request({'Authorization': 'Bearer ' + relay.TOKEN, + 'Transfer-Encoding': 'chunked'}, method='POST')) + + def test_authorized_request_forwards_only_upstream_key(self): + relay.KEY = 'private-upstream-test-key' + response = unittest.mock.Mock(status=200, headers={'Content-Type': 'application/json'}) + response.read1.side_effect = [b'{"data":[]}', b''] + connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port) + with patch.object(relay.urllib.request, 'urlopen', return_value=response) as upstream, patch.object(relay, 'record'): + connection.request('GET', '/v1/models', headers={'Authorization': 'Bearer ' + relay.TOKEN}) + result = connection.getresponse() + self.assertEqual(200, result.status) + self.assertEqual(b'{"data":[]}', result.read()) + self.assertEqual('Bearer private-upstream-test-key', upstream.call_args.args[0].get_header('Authorization')) + connection.close() + + def test_authorized_post_forwards_json_unchanged(self): + relay.KEY = 'private-upstream-test-key' + body = b'{"model":"test-model","messages":[],"stream":true}' + response = unittest.mock.Mock(status=200, headers={'Content-Type': 'text/event-stream'}) + response.read1.side_effect = [b'data: [DONE]\n', b''] + connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port) + with patch.object(relay.urllib.request, 'urlopen', return_value=response) as upstream, patch.object(relay, 'record'): + connection.request('POST', '/v1/chat/completions', body=body, + headers={'Authorization': 'Bearer ' + relay.TOKEN}) + result = connection.getresponse() + self.assertEqual(200, result.status) + self.assertEqual(b'data: [DONE]\n', result.read()) + self.assertEqual(body, upstream.call_args.args[0].data) + self.assertEqual('POST', upstream.call_args.args[0].get_method()) + connection.close() + + +if __name__ == '__main__': + unittest.main() diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadTerminalServiceShape.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadTerminalServiceShape.kt index 76faf05..51c727b 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadTerminalServiceShape.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadTerminalServiceShape.kt @@ -12,6 +12,7 @@ import org.zoocode.jetbrains.core.PluginContext import org.zoocode.jetbrains.terminal.TerminalInstance import org.zoocode.jetbrains.terminal.TerminalInstanceManager import org.zoocode.jetbrains.terminal.TerminalConfig +import org.zoocode.jetbrains.terminal.decodeTerminalPreserveFocus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -296,7 +297,9 @@ class MainThreadTerminalService(private val project: Project) : MainThreadTermin val terminalInstance = getTerminalInstance(id) if (terminalInstance != null) { - terminalInstance.show(preserveFocus ?: true) + // The upstream Terminal.show default is preserveFocus=false, which + // requests focus. Only a real true preserves the current focus owner. + terminalInstance.show(decodeTerminalPreserveFocus(preserveFocus)) logger.info("✅ Terminal shown: $id") } else { logger.warn("Terminal does not exist: $id") diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadTextEditorsShape.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadTextEditorsShape.kt index fc4f983..b65e6e7 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadTextEditorsShape.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadTextEditorsShape.kt @@ -12,6 +12,7 @@ import com.intellij.openapi.vfs.LocalFileSystem import org.zoocode.jetbrains.editor.EditorAndDocManager import org.zoocode.jetbrains.editor.Range import org.zoocode.jetbrains.editor.createURI +import org.zoocode.jetbrains.editor.decodePreserveFocus import kotlinx.coroutines.delay import java.io.File @@ -135,14 +136,15 @@ class MainThreadTextEditors(var project: Project) : MainThreadTextEditorsShape { private val logger = Logger.getInstance(MainThreadTextEditors::class.java) override suspend fun tryShowTextDocument(resource: Map, options: Any?): Any? { - logger.info("Trying to show text document: resource=$resource, options=$options") + val preserveFocus = decodePreserveFocus(options) + logger.info("Trying to show text document, preserveFocus=$preserveFocus") val path = resource["path"] as String? ?: "" val vfs = LocalFileSystem.getInstance() vfs.refreshIoFiles(listOf(File(path))) val resourceURI = createURI(resource) - val editorHandle = project.getService(EditorAndDocManager::class.java).openEditor(resourceURI) - logger.info("Trying to show text document: resource=$resource execution completed" ) + val editorHandle = project.getService(EditorAndDocManager::class.java).openEditor(resourceURI, preserveFocus) + logger.info("Trying to show text document: execution completed") return editorHandle.id } diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadWebviewViewsShape.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadWebviewViewsShape.kt index 5c8f513..ed91071 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadWebviewViewsShape.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/actors/MainThreadWebviewViewsShape.kt @@ -5,9 +5,13 @@ package org.zoocode.jetbrains.actors import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindowManager import org.zoocode.jetbrains.events.WebviewViewProviderData +import org.zoocode.jetbrains.util.FocusRequestEpoch +import org.zoocode.jetbrains.util.ManualFocusGuard import org.zoocode.jetbrains.webview.WebViewManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -67,13 +71,16 @@ class MainThreadWebviewViews(val project: Project) : MainThreadWebviewViewsShape private val logger = Logger.getInstance(MainThreadWebviewViews::class.java) private val coroutineScope = CoroutineScope(Dispatchers.Default) + // Bounded request invalidation: a newer show supersedes queued show work + private val showEpoch = FocusRequestEpoch() + override fun registerWebviewViewProvider( extension: Map, viewType: String, options: Map ) { logger.info("Registering Webview view provider: viewType=$viewType, options=$options") - + // Use EventBus to send WebView view provider registration event, using IntelliJ platform compatible method // project.getService(ProjectEventBus::class.java).emitInApplication( // WebviewViewProviderRegisterEvent, @@ -98,11 +105,69 @@ class MainThreadWebviewViews(val project: Project) : MainThreadWebviewViewsShape logger.info("Setting Webview view badge: handle=$handle, badge=$badge") } + /** + * Shows the requested webview view. + * + * A preserving request only reveals the tool window without activation. + * A deliberate request activates the view and targets the browser keyboard + * component once, so OSR keystrokes reach the page without a second click. + * A deliberate request observes focus-owner moves while it waits in the + * queue through a bounded [ManualFocusGuard]: a manual user choice between + * the request and its execution cancels the activation. A preserving + * request is background work and is not cancelled by a manual move. + * Unknown and stale handles are rejected. + */ override fun show(handle: String, preserveFocus: Boolean) { - logger.info("Showing Webview view: handle=$handle, preserveFocus=$preserveFocus") + logger.info("Showing Webview view, preserveFocus=$preserveFocus") + val webViewManager = project.getService(WebViewManager::class.java) + val epoch = showEpoch.next() + val guard = if (preserveFocus) null else ManualFocusGuard().apply { start() } + ApplicationManager.getApplication().invokeLater { + try { + if (project.isDisposed) { + return@invokeLater + } + if (!showEpoch.isCurrent(epoch)) { + return@invokeLater + } + val webView = webViewManager.getLatestWebView() + if (webView == null || webView.viewId != handle) { + logger.warn("Rejected Webview view show for a handle that is not the current view") + return@invokeLater + } + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(ZOO_CODE_TOOL_WINDOW_ID) + if (toolWindow == null || toolWindow.isDisposed) { + logger.warn("Rejected Webview view show: tool window is not available") + return@invokeLater + } + if (preserveFocus) { + toolWindow.show(null) + } else { + if (guard?.focusMoved() == true) { + logger.info("Skipped deliberate Webview view show: the user moved focus first") + return@invokeLater + } + toolWindow.activate { + if (project.isDisposed || !showEpoch.isCurrent(epoch)) { + return@activate + } + if (webView.isDisposed || webViewManager.getLatestWebView() !== webView) { + return@activate + } + webView.browser.component.requestFocusInWindow() + } + } + } finally { + guard?.close() + } + } } override fun dispose() { logger.info("Disposing MainThreadWebviewViews resources") } -} \ No newline at end of file + + private companion object { + const val ZOO_CODE_TOOL_WINDOW_ID = "Zoo Code JetBrains" + } +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/editor/EditorAndDocManager.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/editor/EditorAndDocManager.kt index 802b609..4a29cd0 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/editor/EditorAndDocManager.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/editor/EditorAndDocManager.kt @@ -208,7 +208,19 @@ class EditorAndDocManager(val project: Project) : Disposable { } - suspend fun openEditor(documentUri: URI ,options: ResolvedTextEditorConfiguration = ResolvedTextEditorConfiguration()): EditorHolder { + /** + * Opens a file editor for the document. + * + * [preserveFocus] carries the display intent from the extension host. The + * [FileEditorManager.openFile] boolean requests editor focus, so a + * preserving open must pass its inverse and must never pass preservation + * itself. + * + * @param documentUri URI of the document to open + * @param preserveFocus true keeps the current focus owner; absent defaults to false + * @param options Editor formatting configuration, separate from display intent + */ + suspend fun openEditor(documentUri: URI, preserveFocus: Boolean = false, options: ResolvedTextEditorConfiguration = ResolvedTextEditorConfiguration()): EditorHolder { val fileEditorManager = FileEditorManager.getInstance(project) val path = documentUri.path var ideaEditor : Array? = null @@ -217,7 +229,9 @@ class EditorAndDocManager(val project: Project) : Disposable { val file = vfs.findFileByPath(path) file?.let { ApplicationManager.getApplication().invokeAndWait { - ideaEditor = fileEditorManager.openFile(it, true) + if (!project.isDisposed) { + ideaEditor = fileEditorManager.openFile(it, editorFocusRequest(preserveFocus)) + } } } val eh = getEditorHandleByUri(documentUri,false) @@ -237,22 +251,47 @@ class EditorAndDocManager(val project: Project) : Disposable { return handle } - suspend fun openDiffEditor(left: URI,documentUri: URI,title:String, options: ResolvedTextEditorConfiguration = ResolvedTextEditorConfiguration()): EditorHolder { + /** + * Opens a diff editor between the left and right documents. + * + * A preserving open cannot go through [DiffEditorTabFilesManager]: the + * baseline helper ignores its focus argument and closes the right-hand + * file first, which can remove the current focus target. It opens the + * chain virtual file through the file editor with focus false instead. + * A deliberate open keeps the existing diff helper behavior. + * + * @param left URI of the left diff side + * @param documentUri URI of the right diff side + * @param title Diff editor tab title + * @param preserveFocus true keeps the current focus owner; absent defaults to false + * @param options Editor formatting configuration, separate from display intent + */ + suspend fun openDiffEditor(left: URI,documentUri: URI,title:String, preserveFocus: Boolean = false, options: ResolvedTextEditorConfiguration = ResolvedTextEditorConfiguration()): EditorHolder { val content2 = createContent(documentUri, project) val content1 = createContent(left, project,content2?.contentType) if (content1 != null && content2 != null){ val request = SimpleDiffRequest(title, content1, content2, left.path, documentUri.path) var ideaEditor : Array? = null ApplicationManager.getApplication().invokeAndWait{ - LocalFileSystem.getInstance().findFileByPath(documentUri.path) - ?.let { - ApplicationManager.getApplication().runReadAction { FileEditorManager.getInstance(project).closeFile(it) } - } + if (!project.isDisposed) { + val requestChain: DiffRequestChain = SimpleDiffRequestChain(request) + val diffFile = ChainDiffVirtualFile(requestChain, DiffBundle.message("label.default.diff.editor.tab.name", *arrayOfNulls(0))) + when (diffOpenPlan(preserveFocus)) { + DiffOpenPlan.PRESERVE_VIA_FILE_EDITOR -> { + ideaEditor = FileEditorManager.getInstance(project) + .openFile(diffFile, editorFocusRequest(preserveFocus)) + } + DiffOpenPlan.DELIBERATE_VIA_DIFF_HELPER -> { + LocalFileSystem.getInstance().findFileByPath(documentUri.path) + ?.let { + ApplicationManager.getApplication().runReadAction { FileEditorManager.getInstance(project).closeFile(it) } + } - val diffEditorTabFilesManager = DiffEditorTabFilesManager.getInstance(project) - val requestChain: DiffRequestChain = SimpleDiffRequestChain(request) - val diffFile = ChainDiffVirtualFile(requestChain, DiffBundle.message("label.default.diff.editor.tab.name", *arrayOfNulls(0))) - ideaEditor = diffEditorTabFilesManager.showDiffFile(diffFile, true) + val diffEditorTabFilesManager = DiffEditorTabFilesManager.getInstance(project) + ideaEditor = diffEditorTabFilesManager.showDiffFile(diffFile, true) + } + } + } } ideaEditor?.let { val handle = sync2ExtHost(documentUri, true, true, options) diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/editor/EditorCommands.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/editor/EditorCommands.kt index 509cc4f..fb4601d 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/editor/EditorCommands.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/editor/EditorCommands.kt @@ -50,27 +50,29 @@ fun registerOpenEditorAPICommands(project: Project,registry: CommandRegistry) { */ class OpenEditorAPICommands(val project: Project) { private val logger = Logger.getInstance(OpenEditorAPICommands::class.java) - + /** * Opens a diff editor to compare two files * * @param left Map containing URI components for the left file * @param right Map containing URI components for the right file * @param title Optional title for the diff editor - * @param columnOrOptions Optional column or options for the diff editor + * @param columnOrOptions Wire `[column, options]` tuple, a direct options map, or null * @return null after operation completes */ suspend fun workbench_diff(left: Map, right : Map, title : String?,columnOrOptions : Any?): Any?{ + val preserveFocus = decodeDiffPreserveFocus(columnOrOptions) val rightURI = createURI(right) val leftURI = createURI(left) - logger.info("Opening diff: ${rightURI.path}") + logger.info("Opening diff, preserveFocus=$preserveFocus") val content1 = createContent(left,project) val content2 = createContent(right,project) if (content1 != null && content2 != null){ - project.getService(EditorAndDocManager::class.java).openDiffEditor(leftURI,rightURI,title?:"File Comparison") + project.getService(EditorAndDocManager::class.java) + .openDiffEditor(leftURI, rightURI, title ?: "File Comparison", preserveFocus) } - logger.info("Opening diff completed: ${rightURI.path}") - return null; + logger.info("Opening diff completed") + return null } /** @@ -115,6 +117,58 @@ class OpenEditorAPICommands(val project: Project) { } } +/** + * Decodes the preserveFocus display option. + * + * The upstream contract treats a missing flag as false, which requests editor + * focus. Only a real boolean changes the intent. A malformed value keeps that + * default and emits a content-free diagnostic, because the value itself is + * never safe to log. + * + * @param options Display options from the extension host, or null + * @return true when the caller asked to keep focus away from the editor + */ +fun decodePreserveFocus(options: Any?): Boolean { + if (options !is Map<*, *>) return false + if (!options.containsKey("preserveFocus")) return false + val value = options["preserveFocus"] + if (value is Boolean) return value + Logger.getInstance("org.zoocode.jetbrains.editor.FocusIntent") + .warn("Ignored malformed preserveFocus display option and used the default") + return false +} + +/** + * vscode.diff is translated by ExtHostApiCommands to _workbench.diff with a + * `[column, options]` tuple. Decoding the tuple itself as a map loses preservation. + * A direct options map is also accepted; absent or malformed values use false. + */ +fun decodeDiffPreserveFocus(columnOrOptions: Any?): Boolean = decodePreserveFocus( + if (columnOrOptions is List<*>) columnOrOptions.getOrNull(1) else columnOrOptions +) + +/** + * Maps the preservation intent to the platform focus boolean. The + * [FileEditorManager.openFile] boolean requests editor focus, so a preserving + * open must request the inverse. Both open calls in [EditorAndDocManager] pass + * through this mapping. + */ +fun editorFocusRequest(preserveFocus: Boolean): Boolean = !preserveFocus + +/** + * Routing decision for a diff open. A preserving open cannot use the baseline + * diff helper, because it ignores its focus argument and closes the right-hand + * file first; it opens the chain virtual file through the file editor with + * focus false instead. A deliberate open keeps the diff helper behavior. + */ +internal enum class DiffOpenPlan { + PRESERVE_VIA_FILE_EDITOR, + DELIBERATE_VIA_DIFF_HELPER +} + +internal fun diffOpenPlan(preserveFocus: Boolean): DiffOpenPlan = + if (preserveFocus) DiffOpenPlan.PRESERVE_VIA_FILE_EDITOR else DiffOpenPlan.DELIBERATE_VIA_DIFF_HELPER + /** * Creates a URI object from a map of URI components * @@ -134,4 +188,4 @@ fun createURI(map: Map): URI { override val fragment: String = fragment } return URI.from(uriComponents) -} \ 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 30afe79..e1b2205 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 @@ -14,6 +14,8 @@ import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowManager import com.intellij.terminal.JBTerminalWidget import com.intellij.terminal.ui.TerminalWidget +import com.intellij.ui.content.Content +import com.intellij.ui.content.ContentManager import com.jediterm.terminal.TtyConnector import com.pty4j.PtyProcess import com.pty4j.PtyProcessBuilder @@ -21,6 +23,10 @@ import org.zoocode.jetbrains.core.ServiceProxyRegistry import org.zoocode.jetbrains.ipc.proxy.IRPCProtocol import org.zoocode.jetbrains.ipc.proxy.interfaces.ExtHostTerminalShellIntegrationProxy import org.zoocode.jetbrains.ipc.proxy.interfaces.ShellLaunchConfigDto +import org.zoocode.jetbrains.util.FocusRequestEpoch +import org.zoocode.jetbrains.util.ManualFocusGuard +import org.zoocode.jetbrains.util.TerminalCreationFocusGuard +import org.zoocode.jetbrains.webview.WebViewManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -28,6 +34,7 @@ import kotlinx.coroutines.cancel import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner import org.jetbrains.plugins.terminal.ShellStartupOptions import org.jetbrains.plugins.terminal.ShellTerminalWidget +import java.awt.Component import java.io.IOException import java.nio.charset.StandardCharsets @@ -67,6 +74,18 @@ class TerminalInstance( private var terminalWidget: TerminalWidget? = null private var shellWidget: ShellTerminalWidget? = null + // Content inserted into the Terminal tool window, with its owning manager; + // retained so a show request can select this terminal without searching + @Volatile + private var terminalContent: Content? = null + + @Volatile + private var terminalContentManager: ContentManager? = null + + // Bounded show-request invalidation: a newer show or a hide supersedes + // queued show work for this instance + private val showEpoch = FocusRequestEpoch() + // State management private val state = TerminalState() @@ -133,14 +152,29 @@ class TerminalInstance( * Perform initialization steps */ private fun performInitialization() { + val focusGuard = try { + TerminalCreationFocusGuard( + project.getService(WebViewManager::class.java).getLatestWebView()?.browser?.component + ) + } catch (e: Exception) { + logger.warn("Could not observe chat focus during terminal creation", e) + null + } try { setupShellIntegration() createTerminalWidget() finalizeInitialization() } catch (e: Exception) { + runCatching { focusGuard?.close() }.onFailure { logger.warn("Could not close chat focus observation", it) } logger.error("❌ Failed to initialize terminal in EDT thread: $extHostTerminalId", e) throw e } + try { + focusGuard?.finish(project, terminalContent?.component) + } catch (e: Exception) { + runCatching { focusGuard?.close() }.onFailure { logger.warn("Could not close chat focus observation", it) } + logger.warn("Could not schedule chat focus restoration after terminal creation", e) + } } /** @@ -394,7 +428,23 @@ class TerminalInstance( } /** - * Show terminal + * Show terminal. + * + * Visibility and focus intent are separate operations. A preserving show + * only reveals the tool window. A deliberate show activates the tool window + * and then requests widget focus once through the supported + * [TerminalWidget.requestFocus] API, with the instance state rechecked + * inside the activation callback. + * + * A deliberate show observes focus-owner moves while it waits in the queue + * through a bounded [ManualFocusGuard]: a manual user choice between the + * request and its execution cancels the activation. A preserving show is + * background work and is not cancelled by a manual move; it never + * activates anything. + * + * The previous [shellWidget].show(preserveFocus) call resolved to + * java.awt.Component.show(boolean), which controls visibility only; the + * flag was dead code and is removed. */ fun show(preserveFocus: Boolean = false) { if (!state.canOperate()) { @@ -402,13 +452,41 @@ class TerminalInstance( return } + val epoch = showEpoch.next() + val guard = if (preserveFocus) null else ManualFocusGuard().apply { start() } ApplicationManager.getApplication().invokeLater { try { - showTerminalToolWindow() - shellWidget?.show(preserveFocus) - logger.info("✅ Terminal shown: $extHostTerminalId") + // Recheck lifetime and supersession inside the queued task + if (!state.canOperate() || project.isDisposed) { + return@invokeLater + } + if (!showEpoch.isCurrent(epoch)) { + return@invokeLater + } + // hide() sets the widget invisible; restore visibility + // independently from the focus intent + restoreWidgetVisibility(shellWidget) + if (preserveFocus) { + showTerminalToolWindow() + selectOwnContent(requestFocus = false) + } else { + if (guard?.focusMoved() == true) { + logger.info("Skipped deliberate terminal show: the user moved focus first: $extHostTerminalId") + return@invokeLater + } + activateTerminalToolWindow { + if (!state.canOperate() || project.isDisposed || !showEpoch.isCurrent(epoch)) { + return@activateTerminalToolWindow + } + selectOwnContent(requestFocus = true) + terminalWidget?.requestFocus() + } + } + logger.info("✅ Terminal shown: $extHostTerminalId, preserveFocus=$preserveFocus") } catch (e: Exception) { logger.error("❌ Failed to show terminal: $extHostTerminalId", e) + } finally { + guard?.close() } } } @@ -422,8 +500,14 @@ class TerminalInstance( return } + // A hide supersedes every queued show: without this, a queued show task + // would run after the hide and resurrect visibility and selection + showEpoch.invalidate() ApplicationManager.getApplication().invokeLater { try { + if (!state.canOperate() || project.isDisposed) { + return@invokeLater + } hideTerminalToolWindow() shellWidget?.hide() logger.info("✅ Terminal hidden: $extHostTerminalId") @@ -434,7 +518,20 @@ class TerminalInstance( } /** - * Show terminal tool window and activate current terminal tab + * Select this terminal's content through the supported non-focusing + * selection API. Does nothing when the content is gone or moved managers. + */ + private fun selectOwnContent(requestFocus: Boolean) { + val content = terminalContent ?: return + val manager = terminalContentManager ?: return + if (manager.contents.none { it === content }) { + return + } + manager.setSelectedContent(content, requestFocus) + } + + /** + * Show terminal tool window without activation */ private fun showTerminalToolWindow() { try { @@ -445,6 +542,18 @@ class TerminalInstance( } } + /** + * Activate the terminal tool window and run the callback once it is active + */ + private fun activateTerminalToolWindow(whenActive: Runnable) { + try { + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(TERMINAL_TOOL_WINDOW_ID) + toolWindow?.activate(whenActive) + } catch (e: Exception) { + logger.error("❌ Failed to activate terminal tool window", e) + } + } + /** * Add terminalWidget to Terminal tool window */ @@ -466,6 +575,8 @@ class TerminalInstance( // Use TerminalToolWindowManager's newTab method to create new Content val content = terminalToolWindowManager.newTab(toolWindow, terminalWidget!!) content.displayName = config.name ?: DEFAULT_TERMINAL_NAME + terminalContent = content + terminalContentManager = toolWindow.contentManager logger.info("✅ Added terminalWidget to Terminal tool window: ${content.displayName}") } catch (e: Exception) { @@ -653,9 +764,29 @@ class TerminalInstance( private fun cleanupResources() { terminalWidget = null shellWidget = null + terminalContent = null + terminalContentManager = null + } +} + +/** + * Restores widget visibility after a hide. Visibility is independent from the + * focus intent: both preserving and deliberate shows need the widget visible. + */ +internal fun restoreWidgetVisibility(widget: Component?) { + if (widget != null && !widget.isVisible) { + widget.isVisible = true } } +/** + * Decodes the terminal [preserveFocus] show argument. The upstream + * Terminal.show default is false, which requests focus; only a real true + * preserves the current focus owner. The bridge call site uses this exact + * default. + */ +internal fun decodeTerminalPreserveFocus(preserveFocus: Boolean?): Boolean = preserveFocus ?: false + /** * Terminal configuration data class */ 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 7a5ac63..b7c358e 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 @@ -269,24 +269,36 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { private var dragDropHandler: DragDropHandler? = null + // The WebView instance whose browser component currently fills the panel. + // Mount decisions compare instances by identity, never by URL or class. + private var mountedWebView: WebViewInstance? = null + + // Placeholder panels registered at construction; removal targets only these + private val placeholderPanels = PlaceholderPanels() + + private fun createPlaceholderPanel(): JPanel { + val panel = JPanel(BorderLayout()).apply { + border = BorderFactory.createEmptyBorder(20, 20, 20, 20) + add(placeholderLabel, BorderLayout.CENTER) + add(buttonPanel, BorderLayout.SOUTH) + } + placeholderPanels.register(panel) + return panel + } + val content: JPanel = JPanel(BorderLayout()).apply { contentPanel.layout = BorderLayout() // 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) - add(buttonPanel, BorderLayout.SOUTH) - } - contentPanel.add(initPanel, BorderLayout.CENTER) + contentPanel.add(createPlaceholderPanel(), BorderLayout.CENTER) } else { val selectionPanel = JPanel(BorderLayout()).apply { border = BorderFactory.createEmptyBorder(20, 20, 20, 20) add(pluginSelectionPanel, BorderLayout.CENTER) add(configStatusPanel, BorderLayout.SOUTH) } + placeholderPanels.register(selectionPanel) contentPanel.add(selectionPanel, BorderLayout.CENTER) } @@ -295,12 +307,7 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { init { // Always show system info panel (no plugin selection needed) - val initPanel = JPanel(BorderLayout()).apply { - border = BorderFactory.createEmptyBorder(20, 20, 20, 20) - add(placeholderLabel, BorderLayout.CENTER) - add(buttonPanel, BorderLayout.SOUTH) - } - contentPanel.add(initPanel, BorderLayout.CENTER) + contentPanel.add(createPlaceholderPanel(), BorderLayout.CENTER) // Don't auto-start here - WecoderPlugin will handle startup // The plugin will start automatically if configuration is valid @@ -313,21 +320,18 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { // Try to get existing WebView webViewManager.getLatestWebView()?.let { webView -> - // Add WebView component immediately when created - ApplicationManager.getApplication().invokeLater { - addWebViewComponent(webView) - } - // Set page load callback to hide system info only after page is loaded + scheduleMountIfCurrent(webView) + // Set page load callback to mount only after page is loaded webView.setPageLoadCallback { ApplicationManager.getApplication().invokeLater { - hideSystemInfo() + if (!toolWindow.isDisposed) { + mountWebView(webView) + } } } - // If page is already loaded, hide system info immediately + // If page is already loaded, mount immediately if (webView.isPageLoaded()) { - ApplicationManager.getApplication().invokeLater { - hideSystemInfo() - } + scheduleMountIfCurrent(webView) } }?:webViewManager.addCreationCallback(this, toolWindow.disposable) } @@ -408,68 +412,119 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { override fun onWebViewCreated(instance: WebViewInstance) { // Add WebView component immediately when created ApplicationManager.getApplication().invokeLater { - addWebViewComponent(instance) + if (!toolWindow.isDisposed) { + mountWebView(instance) + } } - // Set page load callback to hide system info only after page is loaded + // Set page load callback to mount only after page is loaded instance.setPageLoadCallback { // Ensure UI update in EDT thread ApplicationManager.getApplication().invokeLater { - hideSystemInfo() + if (!toolWindow.isDisposed) { + mountWebView(instance) + } } } } /** - * Add WebView component to UI + * Mounts the WebView when this content still owns the tool window and + * the instance is still the manager's current one. */ - private fun addWebViewComponent(webView: WebViewInstance) { - logger.info("Adding WebView component to UI: ${webView.viewType}/${webView.viewId}") - - // Check if WebView component is already added - val components = contentPanel.components - for (component in components) { - if (component === webView.browser.component) { - logger.info("WebView component already exists in UI") + private fun scheduleMountIfCurrent(webView: WebViewInstance) { + ApplicationManager.getApplication().invokeLater { + if (toolWindow.isDisposed) { + return@invokeLater + } + if (webViewManager.getLatestWebView() !== webView) { + return@invokeLater + } + mountWebView(webView) + } + } + + /** + * Idempotent mount keyed on instance identity. + * + * A repeated mount of the already mounted browser detaches nothing and + * requests no focus. Only a genuinely replaced browser is detached once, + * and the retired instance is disposed at that handoff so its callbacks + * cannot touch the hierarchy afterwards. + */ + private fun mountWebView(webView: WebViewInstance) { + if (toolWindow.isDisposed) { + return + } + // A late callback from a replaced instance must never touch the hierarchy + if (webViewManager.getLatestWebView() !== webView) { + logger.info("Skipped mount of replaced WebView: ${webView.viewType}/${webView.viewId}") + return + } + + val browserComponent = webView.browser.component + when (planMount(mountedWebView, webView, browserComponent.parent === contentPanel)) { + MountAction.KEEP_MOUNTED -> { + logger.info("WebView already mounted, removing placeholders only: ${webView.viewType}/${webView.viewId}") + removePlaceholderPanels() + return + } + MountAction.REMOUNT_SAME -> { + // The identity shortcut must still honor the actual parent: + // updateUIContent or a theme change can detach the browser + // while mountedWebView still points at this instance. + contentPanel.add(browserComponent, BorderLayout.CENTER) + contentPanel.revalidate() + contentPanel.repaint() + removePlaceholderPanels() + webViewManager.markMountedInstance(webView) + logger.info("WebView remounted after detachment: ${webView.viewType}/${webView.viewId}") return } + MountAction.REPLACE -> {} } - // Add WebView component without removing existing components - contentPanel.add(webView.browser.component, BorderLayout.CENTER) + val replaced = mountedWebView + if (replaced != null) { + val replacedComponent = replaced.browser.component + if (replacedComponent !== browserComponent && replacedComponent.parent === contentPanel) { + contentPanel.remove(replacedComponent) + } + } + + if (browserComponent.parent !== contentPanel) { + contentPanel.add(browserComponent, BorderLayout.CENTER) + } setupDragAndDropSupport(webView) - // Relayout + removePlaceholderPanels() + contentPanel.revalidate() contentPanel.repaint() + mountedWebView = webView + webViewManager.markMountedInstance(webView) + + // The replaced instance has no mounted consumer left; retire it once + // here so its browser, handlers, and queued work stop at the handoff + if (replaced != null) { + try { + webViewManager.retireInstance(replaced) + } catch (e: Exception) { + logger.error("Failed to retire replaced WebView instance", e) + } + } - logger.info("WebView component added to tool window") + logger.info("WebView component mounted: ${webView.viewType}/${webView.viewId}, generation=${webView.generation}") } /** - * Hide system info placeholder + * Remove only the placeholder panels registered by this content */ - private fun hideSystemInfo() { - logger.info("Hiding system info placeholder") - - // Remove all components from content panel except WebView component - val components = contentPanel.components - for (component in components) { - if (component !== webViewManager.getLatestWebView()?.browser?.component) { - contentPanel.remove(component) - } + private fun removePlaceholderPanels() { + if (placeholderPanels.removeAttached(contentPanel)) { + contentPanel.revalidate() + contentPanel.repaint() } - - webViewManager.getLatestWebView()?.let { webView -> - contentPanel.remove(webView.browser.component) - contentPanel.add(webView.browser.component, BorderLayout.CENTER) - } - - // Relayout - contentPanel.revalidate() - contentPanel.repaint() - - logger.info("System info placeholder hidden") } /** @@ -948,16 +1003,15 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { return } + val unmounted = mountedWebView contentPanel.removeAll() + // removeAll detached the browser; forget the stale mount reference + // and release its mounted marker so a later mount can re-attach it + mountedWebView = null + unmounted?.let { webViewManager.clearMountedInstance(it) } // 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) - add(buttonPanel, BorderLayout.SOUTH) - } - contentPanel.add(initPanel, BorderLayout.CENTER) + contentPanel.add(createPlaceholderPanel(), BorderLayout.CENTER) logger.info("Showing system info panel - Zoo Code auto-configured") contentPanel.revalidate() @@ -1022,3 +1076,51 @@ class ZooCodeJetBrainsToolWindowFactory : ToolWindowFactory { } } } + +/** + * Mount decision for the tool window content, derived from instance identity + * and the actual parent of the browser component. + */ +internal enum class MountAction { + /** Same instance already attached; only placeholder cleanup is needed. */ + KEEP_MOUNTED, + + /** Same instance but detached from the panel; attach it again. */ + REMOUNT_SAME, + + /** A different instance replaces the mounted one. */ + REPLACE +} + +internal fun planMount(currentMounted: T?, incoming: T, incomingAttached: Boolean): MountAction = when { + currentMounted !== incoming -> MountAction.REPLACE + incomingAttached -> MountAction.KEEP_MOUNTED + else -> MountAction.REMOUNT_SAME +} + +/** + * Registration of known placeholder panels. Removal only detaches panels that + * are still attached to the given parent and drops entries that left the + * hierarchy some other way, so repeated update cycles cannot grow the list. + */ +internal class PlaceholderPanels { + private val panels = mutableListOf() + + fun register(panel: JPanel) { + panels.add(panel) + } + + fun removeAttached(parent: javax.swing.JComponent): Boolean { + var removed = false + val iterator = panels.iterator() + while (iterator.hasNext()) { + val panel = iterator.next() + if (panel.parent === parent) { + parent.remove(panel) + removed = true + } + iterator.remove() + } + return removed + } +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/FocusRequestEpoch.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/FocusRequestEpoch.kt new file mode 100644 index 0000000..e86b6c8 --- /dev/null +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/FocusRequestEpoch.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package org.zoocode.jetbrains.util + +import java.util.concurrent.atomic.AtomicInteger + +/** + * Bounded invalidation for queued focus requests. Each request takes an epoch + * number; a newer request or an invalidation supersedes every older queued + * request, so delayed activation can never override newer preserving, hiding, + * or manual intent. + */ +class FocusRequestEpoch { + private val counter = AtomicInteger(0) + + fun next(): Int = counter.incrementAndGet() + + /** + * Supersedes every queued request without creating a new one. Hide and + * disposal paths call this so a queued show can no longer resurrect the + * target it was queued for. + */ + fun invalidate() { + counter.incrementAndGet() + } + + fun isCurrent(epoch: Int): Boolean = counter.get() == epoch +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/ManualFocusGuard.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/ManualFocusGuard.kt new file mode 100644 index 0000000..2ca54dc --- /dev/null +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/ManualFocusGuard.kt @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package org.zoocode.jetbrains.util + +import java.awt.KeyboardFocusManager +import java.beans.PropertyChangeListener +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Bounded manual-focus guard for one queued deliberate focus request. + * + * The guard starts when the request is created and any Swing focus-owner move + * while the request waits in the EDT queue cancels the queued request. The + * delayed activation checks [focusMoved] before starting activation and aborts + * to leave the current owner in place. This never requests, restores, or repairs + * focus. Terminal creation separately uses [TerminalCreationFocusGuard] for one + * evidence-backed restoration; this deliberate-show guard remains cancellation only. + * Its listener lives only until the queued request executes or is + * dropped: the caller disposes it in the EDT task, so the observation window is + * one queued request and never a permanent listener. + */ +class ManualFocusGuard : AutoCloseable { + private val started = AtomicBoolean(false) + private val closed = AtomicBoolean(false) + private val moved = AtomicBoolean(false) + + @Volatile + private var listener: PropertyChangeListener? = null + + /** + * Starts observing focus-owner moves. Idempotent; safe from any thread. + */ + @Synchronized + fun start() { + if (closed.get() || !started.compareAndSet(false, true)) { + return + } + val changeListener = PropertyChangeListener { noteFocusMove() } + listener = changeListener + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .addPropertyChangeListener(FOCUS_OWNER_PROPERTY, changeListener) + } + + /** + * True when the focus owner changed while this guard was active, so a + * delayed activation must abort and leave the current owner in place. + */ + fun focusMoved(): Boolean = moved.get() + + internal fun noteFocusMove() { + if (!closed.get()) { + moved.set(true) + } + } + + /** + * Stops observing. Idempotent; the queued task calls this when it executes + * so the observation window stays finite. + */ + @Synchronized + override fun close() { + if (!closed.compareAndSet(false, true)) { + return + } + listener?.let { + KeyboardFocusManager.getCurrentKeyboardFocusManager() + .removePropertyChangeListener(FOCUS_OWNER_PROPERTY, it) + } + listener = null + } + + private companion object { + const val FOCUS_OWNER_PROPERTY = "focusOwner" + } +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/TerminalCreationFocusGuard.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/TerminalCreationFocusGuard.kt new file mode 100644 index 0000000..a21bbb4 --- /dev/null +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/util/TerminalCreationFocusGuard.kt @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package org.zoocode.jetbrains.util + +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.IdeFocusManager +import com.intellij.openapi.wm.ToolWindowManager +import java.awt.AWTEvent +import java.awt.Component +import java.awt.KeyboardFocusManager +import java.awt.Toolkit +import java.awt.event.AWTEventListener +import java.awt.event.KeyEvent +import java.awt.event.MouseEvent +import javax.swing.SwingUtilities +import javax.swing.Timer + +/** + * One restoration after the platform's focus-requesting terminal insertion. + * Created and completed on the EDT around one synchronous initialization. + * Input observation spans initialization; the 1500 ms callback budget starts + * after creation. Initialization failures close the guard at the call site. + */ +class TerminalCreationFocusGuard internal constructor( + private val chat: Component?, + private val environment: TerminalCreationFocusEnvironment +) : AutoCloseable { + constructor(chat: Component?) : this(chat, SwingTerminalCreationFocusEnvironment) + + private val original = environment.focusOwner() + private val ownedChat = contains(chat, original) + private var userIntervened = false + private var closed = false + private var finished = false + private var expiry: AutoCloseable? = null + private val inputObservation: AutoCloseable? = if (ownedChat) { + environment.observeInput(AWTEventListener { event -> + if ((event is MouseEvent && event.id == MouseEvent.MOUSE_PRESSED || + event is KeyEvent && event.id == KeyEvent.KEY_PRESSED) && + (!contains(chat, event.source as? Component) || + event is KeyEvent && event.keyCode == KeyEvent.VK_TAB)) { + userIntervened = true + } + }) + } else null + + fun finish(project: Project, terminal: Component?) { + if (finished || closed) return + finished = true + if (!ownedChat || project.isDisposed) { + close() + return + } + expiry = environment.expire { close() } + environment.afterCreation(project) { + val restore = terminalCreationFocusRestore( + ownedChat, contains(terminal, environment.focusOwner()), userIntervened, + !project.isDisposed && original?.let(environment::isShowing) == true, closed + ) + close() + if (restore) original?.let(environment::requestFocus) + } + } + + override fun close() { + if (closed) return + closed = true + try { + expiry?.close() + } finally { + inputObservation?.close() + } + } + + private fun contains(root: Component?, owner: Component?): Boolean = + root != null && owner != null && (root === owner || SwingUtilities.isDescendingFrom(owner, root)) +} + +/** Platform boundary: tests exercise the real guard lifecycle with controlled queued events. */ +internal interface TerminalCreationFocusEnvironment { + fun focusOwner(): Component? + fun observeInput(listener: AWTEventListener): AutoCloseable + fun expire(action: () -> Unit): AutoCloseable + fun afterCreation(project: Project, action: () -> Unit) + fun isShowing(component: Component): Boolean + fun requestFocus(component: Component) +} + +private object SwingTerminalCreationFocusEnvironment : TerminalCreationFocusEnvironment { + override fun focusOwner(): Component? = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner + override fun observeInput(listener: AWTEventListener): AutoCloseable { + val toolkit = Toolkit.getDefaultToolkit() + toolkit.addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK or AWTEvent.KEY_EVENT_MASK) + return AutoCloseable { toolkit.removeAWTEventListener(listener) } + } + override fun expire(action: () -> Unit): AutoCloseable { + val timer = Timer(1500) { action() }.apply { isRepeats = false } + timer.start() + return AutoCloseable { timer.stop() } + } + override fun afterCreation(project: Project, action: () -> Unit) { + // Inline focus settling runs before newTab's queued activation. + ToolWindowManager.getInstance(project).invokeLater { + if (project.isDisposed) action() + else IdeFocusManager.getInstance(project).doWhenFocusSettlesDown { action() } + } + } + override fun isShowing(component: Component): Boolean = component.isShowing + override fun requestFocus(component: Component) { component.requestFocusInWindow() } +} + +/** Only the observed creation theft qualifies; unrelated focus and expired work do not. */ +fun terminalCreationFocusRestore( + chatOwnedFocus: Boolean, + createdTerminalOwnsFocus: Boolean, + userIntervened: Boolean, + chatStillAvailable: Boolean, + expired: Boolean +): Boolean = chatOwnedFocus && createdTerminalOwnsFocus && !userIntervened && chatStillAvailable && !expired 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 e1dfebb..4bf4169 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 @@ -27,6 +27,7 @@ import org.zoocode.jetbrains.theme.ThemeManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import org.cef.CefSettings import org.cef.browser.CefBrowser @@ -42,6 +43,7 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.util.* +import java.util.concurrent.atomic.AtomicLong import javax.swing.SwingUtilities import kotlin.io.path.createDirectories import kotlin.io.path.exists @@ -69,6 +71,17 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { @Volatile private var latestWebView: WebViewInstance? = null + // Monotonic generation counter; each registered instance gets the next value + private val nextGeneration = AtomicLong(1) + + // Single retirement owner for instances replaced by newer registrations + private val retirementTracker = InstanceRetirementTracker() + + // Serializes ownership publication against the shutdown claim, so a + // registration that races with dispose either publishes before the claim + // and gets disposed, or sees the disposed flag and refuses to create + private val ownershipLock = Any() + // Store WebView creation callbacks private val creationCallbacks = mutableListOf() @@ -82,7 +95,8 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { // Current theme type private var isDarkTheme: Boolean = true - // Prevent repeated dispose + // Prevent repeated dispose; registration and EDT callbacks also read this flag. + @Volatile private var isDisposed = false private var themeInitialized = false @@ -171,10 +185,13 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { } } - // If there is already a latest created WebView, notify immediately + // If there is already a latest created WebView, notify immediately. + // Recheck inside the EDT task so a retired instance is never delivered. latestWebView?.let { webview -> ApplicationManager.getApplication().invokeLater { - callback.onWebViewCreated(webview) + if (!isDisposed && latestWebView === webview) { + callback.onWebViewCreated(webview) + } } } } @@ -190,7 +207,9 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { } /** - * Notify all callbacks that WebView has been created + * Notify all callbacks that WebView has been created. + * Delivery is rejected when the manager is disposed or when the instance + * was already replaced by a newer registration. * @param instance Created WebView instance */ private fun notifyWebViewCreated(instance: WebViewInstance) { @@ -200,6 +219,10 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { // Safely call callbacks in UI thread ApplicationManager.getApplication().invokeLater { + if (isDisposed || latestWebView !== instance) { + logger.info("Rejected stale WebView creation delivery, generation=${instance.generation}") + return@invokeLater + } callbacks.forEach { callback -> try { callback.onWebViewCreated(instance) @@ -214,6 +237,10 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { * Register WebView provider and create WebView instance */ fun registerProvider(data: WebviewViewProviderData) { + if (isDisposed) { + logger.info("Refused WebView provider registration after shutdown: ${data.viewType}") + return + } logger.info("Register WebView provider and create WebView instance: ${data.viewType}") val extension = data.extension @@ -263,20 +290,42 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { // When registration event is notified, create a new WebView instance val viewId = UUID.randomUUID().toString() + val generation = nextGeneration.getAndIncrement() 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 webview = WebViewInstance(data.viewType, viewId, generation, title, state, project, data.extension) + + // Publish identity before resolve delivery: the extension resolves the + // view and can assign and load HTML while this call is still in flight. + // The disposed recheck under the ownership lock closes the race with a + // concurrent shutdown claim. + val published = synchronized(ownershipLock) { + if (isDisposed) { + return@synchronized false + } + val previous = latestWebView + latestWebView = webview + if (previous != null) { + retirementTracker.track(previous) + } + // Retire instances superseded twice: they can no longer be the UI's + // mounted browser and no handoff will ever dispose them + retirementTracker.orphans().forEach { disposeInstance(it) } + true + } + if (!published) { + logger.info("Refused WebView instance publication after shutdown: ${data.viewType}") + // Disposal of a refused instance uses the same EDT path, outside the lock. + disposeInstance(webview) + return + } val proxy = protocol.getProxy(ServiceProxyRegistry.ExtHostContext.ExtHostWebviewViews) proxy.resolveWebviewView(viewId, data.viewType, title, state, null) - - // Set as the latest created WebView - latestWebView = webview - - logger.info("Create WebView instance: viewType=${data.viewType}, viewId=$viewId") + logger.info("Create WebView instance: viewType=${data.viewType}, viewId=$viewId, generation=$generation") // Notify callback notifyWebViewCreated(webview) @@ -289,12 +338,70 @@ class WebViewManager(var project: Project) : Disposable, ThemeChangeListener { return latestWebView } + /** + * Records the instance the tool window currently mounts, so retirement + * never disposes a browser that is still on screen. + */ + fun markMountedInstance(instance: WebViewInstance) { + if (isDisposed) { + return + } + retirementTracker.markMounted(instance) + } + + fun clearMountedInstance(instance: WebViewInstance) { + if (isDisposed) { + return + } + retirementTracker.clearMounted(instance) + } + + /** + * Single retirement entry for a superseded instance. Refuses the + * still-current instance, owns the dispose-once decision, and is safe to + * call from the EDT mount handoff. + */ + fun retireInstance(instance: WebViewInstance) { + if (isDisposed) { + return + } + if (latestWebView === instance) { + logger.warn("Refused retirement of the still-current WebView: ${instance.viewType}/${instance.viewId}") + return + } + if (!retirementTracker.retireMounted(instance)) { + return + } + disposeInstance(instance) + } + + private fun disposeInstance(instance: WebViewInstance) { + if (instance.isDisposed) { + return + } + // Native browser disposal belongs on the EDT; recheck inside the task + ApplicationManager.getApplication().invokeLater { + if (instance.isDisposed || latestWebView === instance || retirementTracker.isMounted(instance)) { + return@invokeLater + } + try { + instance.dispose() + } catch (e: Exception) { + logger.error("Failed to retire WebView instance ${instance.viewType}/${instance.viewId}", e) + } + } + } + /** * Update the HTML content of the WebView * @param data HTML update data */ - fun updateWebViewHtml(data: WebviewHtmlUpdateData) { - data.htmlContent = data.htmlContent.replace("/jetbrains/resources/roocode/", "./") + fun updateWebViewHtml(data: WebviewHtmlUpdateData) { + if (isDisposed) { + logger.warn("Refused WebView HTML update after shutdown: handle=${data.handle}") + return + } + data.htmlContent = data.htmlContent.replace("/jetbrains/resources/roocode/", "./") data.htmlContent = data.htmlContent.replace("", "") val encodedState = getLatestWebView()?.state.toString().replace("\"", "\\\"") // Support both