commit cbe41e4f4f269a30bd38361b9e5859d66f674538 Author: harmness Date: Thu Sep 10 13:27:43 2026 +0800 Initialize Harmness Browser JetBrains plugin skeleton - Gradle + Kotlin + IntelliJ Platform SDK build - JCEF embedded browser tool window with configurable start URL - Settings page (Tools > Harmness Browser) - JS<->Java bridge scaffold (window.harmnessBridge) - Vite + React + TypeScript web scaffold for embedded page - README: build, register, upload to Marketplace, install guide diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab9b601 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Gradle +.gradle/ +build/ + +# IDE +.idea/ +*.iml +.vscode/ + +# Node +node_modules/ +web/dist/ + +# OS +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4d131d --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# Harmness Browser — JetBrains 插件 + +在 JetBrains IDE(IntelliJ IDEA / PyCharm / PhpStorm / WebStorm 等)的侧边栏内嵌一个 +**可配置地址的浏览器窗口**。默认搭配本地 [Harness](https://harness.io/) 这类 AI 助手 +Web 服务使用,让你不用切出 IDE 就能在代码区与 AI 界面之间来回操作。 + +本仓库已初始化: + +- **JetBrains 插件本体**(Kotlin + Gradle + IntelliJ Platform SDK,`src/`) +- **TypeScript Web 前端脚手架**(Vite + React + TS,`web/`,用于构建/调试将被嵌入的页面) +- **JS ↔ 插件双向桥** 示例(页面上直接调用 `window.harmnessBridge`) + +## 项目结构 + +``` +. +├── build.gradle.kts # Gradle 构建(IntelliJ Platform plugin build) +├── settings.gradle.kts +├── gradle.properties +├── scripts/ +│ └── setup-toolchain.sh # 一键下载 JDK17 + Gradle(无 root 环境) +├── src/main/ +│ ├── kotlin/com/harmness/toolwindow/ +│ │ ├── HarmnessBrowserPanel.kt # JCEF 内嵌浏览器 + JS↔Java 桥 +│ │ ├── HarmnessToolWindowFactory.kt # 侧边栏工具窗口 +│ │ ├── HarmnessSettingsConfigurable.kt # 设置页入口 +│ │ ├── HarmnessBrowserConfigurablePanel.kt # 设置 UI(起始地址) +│ │ ├── SettingsState.kt # 持久化设置存储 +│ │ └── WindowFocusService.kt # 工具窗口焦点广播(预留) +│ └── resources/ +│ ├── META-INF/plugin.xml # 插件声明(扩展点注册) +│ └── icons/harmness.svg # 工具窗口图标 +└── web/ # (可选)被嵌入页面的前端脚手架 + ├── package.json + ├── vite.config.ts + └── src/{main.tsx, App.tsx, bridge.ts, ...} +``` + +## 本地构建 + +### 1. 准备工具链(本机没有 JDK/Gradle 时) + +```bash +./scripts/setup-toolchain.sh +# 会下载 Temurin JDK 17 与 Gradle 8.11.1 到 .tools/ +``` + +若本机已装 JDK 17+ 与 Gradle,可跳过,直接用系统的。 + +### 2. 构建插件 zip + +```bash +export JAVA_HOME=/data/project/Plugin/harmness/.tools/jdk-17.0.12+7 # 若用脚本版 +./.tools/gradle-8.11.1/bin/gradle buildPlugin -x test +``` + +产物:`build/distributions/harmness-toolwindow-0.1.0.zip` + +> 首次构建会下载 IntelliJ Platform SDK(约 1–2GB),请保持网络畅通。 + +### 3. 在 IDE 里运行/调试 + +在 IDEA(Ultimate 或 Community 均可)中: + +1. `File → Open` 打开本仓库根目录(Gradle 项目会自动识别)。 +2. `Gradle → Tasks → intellij → runIde`,或运行主类 + `com.intellij.idea.Main`(由 Gradle 任务 `runIde` 提供)。 +3. 等 IDE 启动后,右侧边栏会出现 **Harmness Browser** 工具窗口。 + +### 4. 前端(可选) + +```bash +cd web +npm install +npm run dev # 起一个位于 http://127.0.0.1:8888 的页面 +``` + +然后在插件设置里把「起始地址」填成 `http://127.0.0.1:8888`,即可在 IDE 侧边栏里看到该页面。 + +## 功能与使用 + +| 功能 | 说明 | +| --- | --- | +| 侧边栏浏览器 | 打开 IDE 后右侧出现可停靠/悬浮的工具窗口 | +| 配置起始地址 | `Settings → Tools → Harmness Browser`(或点工具栏 ⚙) | +| 地址栏 | 手动输入任意地址回车跳转 | +| 前进/后退/刷新 | 工具栏 ← → ⟳ | +| 外部浏览器打开 | 工具栏 ⧉ 把当前地址丢给系统浏览器 | +| JS↔Java 桥 | 页面加载后注入 `window.harmnessBridge(payload)`,网页可调用插件能力(原型:`ping` / `getUrl` / `openExternal:*`) | + +### JS↔Java 桥(给 Harness 前端用) + +`web/src/bridge.ts` 提供类型化封装: + +```ts +import { callPlugin, openInExternalBrowser, initBridge } from './bridge' + +initBridge(( ) => console.log('bridge ready')) + +const pong = await callPlugin('ping') // -> "pong" +openInExternalBrowser('https://example.com') // 用系统浏览器打开 +``` + +如果想要 Harness(或其前端)在收到网页请求时把代码插入编辑器、打开文件等, +需要在 `HarmnessBrowserPanel.setupBridge()` 的 handler 里扩展桥协议, +并通过 IntelliJ Platform API(`FileDocumentManager`、`EditorFactory`、`CodeInsightUtil` 等) +实现。IntelliJ 官方插件 SDK 不提供对 JCEF 页面 DOM 的直接访问,双向通信一律走 +`JBCefJSQuery`(本插件已封装)。 + +## 发布到 JetBrains Marketplace + +### 1. 注册 JetBrains 账号 + +访问 注册/登录(可用 Google / GitHub 账号)。 + +### 2. 申请插件仓库(只有一次) + +1. 打开 并登录。 +2. 右上角头像 → **Developer Console**(或直接 )。 +3. 点击 **Create New Plugin**(第一次需要填组织/开发者信息 + 同意 + [Plugin Hosting Terms](https://plugins.jetbrains.com/docs/marketplace/plugin-hosting-terms.html))。 +4. 创建后会得到一个 **Plugin ID**,把 `plugin.xml` 里的 ``(当前为 + `com.harmness.toolwindow`)改成该 ID(建议保持相同,便于后续关联)。 + +### 3. 构建并本地验证产物 + +```bash +./.tools/gradle-8.11.1/bin/gradle buildPlugin -x test +``` + +生成的 `build/distributions/*.zip` 即发布物。 + +可选(推荐):在 IDE 里用 `Settings → Plugins → ⚙ → Install Plugin from Disk…` +选这个 zip 验证一次,再上传。 + +### 4. 上传发布 + +1. 进入 ,选你的插件。 +2. **Upload Plugin** 上传 zip。 +3. 填写版本信息: + - **Version**:与 `build.gradle.kts` 的 `version` 一致(如 `0.1.0`) + - **Since / Until build**:对应 `plugin.xml` 的 `242 / 243.*` + - **Changelog**:版本变更说明 + - **Compatibility**:勾选目标 IDE(IDEA Community/Ultimate 等) +4. 提交后,若勾选 **Publish**(或之后手动点 Publish),插件会进入审核,一般几分钟到数小时; + 审核通过后即可在 Marketplace 搜索到。 + +> 之后每次发新版,重复第 3–4 步即可。也可用 CI(GitHub Actions + JetBrains +> [publish plugin task](https://plugins.jetbrains.com/docs/intellij/publishing-plugin.html), +> `gradle publishPlugin`)自动化。 + +### 5. 其他人安装使用 + +- **IDE 内**:`Settings → Plugins → Marketplace`,搜索 `Harmness Browser` 安装。 +- **手动**:`Settings → Plugins → ⚙ → Install Plugin from Disk…` 选 zip。 +- **下载**:(你的插件主页)。 + +安装后在右侧栏点开 **Harmness Browser**,到 +`Settings → Tools → Harmness Browser` 把地址配置成你的 Harness 实例即可。 + +## 与 Harness 的适配注意点(重要) + +1. **嵌入限制**:目标 Web 应用(Harness)必须允许被嵌入渲染: + - 不能返回 `X-Frame-Options: DENY/SAMEORIGIN`, + - CSP 里不能有 `frame-ancestors 'none'`。 + 若 Harness 有这些限制,需要在其反向代理/HTTP 头里放行,或使用 Harness 专门的无 frame 限制入口。 +2. **登录态**:cookie 与本地存储默认按 JCEF 的浏览器实例隔离,登录 Harness 后一般会保留 + (IDE 重启后缓存仍有效,除非换 IDE/机器)。 +3. **性能与授权**:JCEF 每次都会初始化一个 Chromium 实例,多个 IDE 窗口会占用较多内存; + 老机器上建议只保留最需要的 IDE 窗口。 +4. **桥接协议**:要把「网页请求 → 编辑代码/打开文件」打通,需按前面「JS↔Java 桥」一节扩展插件。 + +## 关于「把 Harness 塞进侧边栏」的可行性评估(给决策用) + +你的想法是合理的,也是目前社区常见的做法(例如在 IDE 里嵌入 ChatGPT / Copilot 网页版)。 +优点: + +- 不用在两个窗口之间来回切换,交互摩擦小; +- 利用 IDE 自带的 JCEF,不需要额外浏览器进程; +- 可以做双向桥,网页 → 编辑器有原生 IPC 通路。 + +局限(需要接受或规避): + +- JCEF = 内置 Chromium,内存占用明显(每个 IDE 窗口一份); +- 官方 SDK 不给页面 DOM 权限,需求超过「纯网页浏览 + 简单桥」就要自己维护桥协议 + (本插件已铺好骨架); +- 第三方网页的嵌入限制(X-Frame-Options / CSP)可能直接挡掉,需要 Harness 侧配合。 + +结论:**方向可行**。建议先把 `web/` 里替换成 Harness 实际前端,跑通「配置地址 → 侧边栏显示 → +桥接 ping」这条链路,再决定扩展哪些 IDE 能力。 + +## License / 后续 + +- 当前版本为原型骨架(0.1.0),默认 `sinceBuild=242, untilBuild=243.*`,只覆盖 2024.2–2024.3。 +- 后续可加:多地址书签、地址访问白名单(安全)、按 IDE 主题切换深/浅色、自动重连等。 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..f0e8311 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id("java") + id("org.jetbrains.kotlin.jvm") version "2.0.21" + id("org.jetbrains.intellij.platform") version "2.5.1" +} + +group = "com.harmness" +version = "0.1.0" + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + intellijIdeaCommunity("2024.2.3") + pluginVerifier() + instrumentationTools() + } +} + +intellijPlatform { + pluginConfiguration { + ideaVersion { + sinceBuild = "242" + untilBuild = "243.*" + } + } +} + +kotlin { + jvmToolchain(17) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..cd23b36 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=false \ No newline at end of file diff --git a/scripts/setup-toolchain.sh b/scripts/setup-toolchain.sh new file mode 100755 index 0000000..43e113a --- /dev/null +++ b/scripts/setup-toolchain.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# 准备本地构建工具链(无 root 安装,全部放到工作区 .tools 下) +set -euo pipefail + +TOOLS_DIR="/data/project/Plugin/harmness/.tools" +mkdir -p "$TOOLS_DIR" +cd "$TOOLS_DIR" + +JDK_VERSION="jdk-17.0.12+7" +GRADLE_VERSION="gradle-8.11.1" + +echo "==> Downloading Temurin JDK17 ..." +curl -fL --retry 3 -o jdk17.tar.gz \ + "https://github.com/adoptium/temurin17-binaries/releases/download/${JDK_VERSION}/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz" +tar xzf jdk17.tar.gz +chmod +x jdk-17.0.12+7/bin/java +"$TOOLS_DIR/jdk-17.0.12+7/bin/java" -version + +echo "==> Downloading Gradle $GRADLE_VERSION ..." +curl -fL --retry 3 -o gradle.zip \ + "https://services.gradle.org/distributions/${GRADLE_VERSION}-bin.zip" +unzip -q gradle.zip +chmod +x "$GRADLE_VERSION/bin/gradle" +JAVA_HOME="$TOOLS_DIR/jdk-17.0.12+7" "$GRADLE_VERSION/bin/gradle" --version + +echo "==> DONE" \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..82a68d2 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "harmness-toolwindow" \ No newline at end of file diff --git a/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserConfigurablePanel.kt b/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserConfigurablePanel.kt new file mode 100644 index 0000000..dffd0e0 --- /dev/null +++ b/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserConfigurablePanel.kt @@ -0,0 +1,45 @@ +package com.harmness.toolwindow + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.FormBuilder +import com.intellij.util.ui.UIUtil +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * 设置面板 UI:编辑「起始地址」并即时生效。 + */ +class HarmnessBrowserConfigurablePanel { + + private val urlField = JBTextField() + + val panel: JPanel = FormBuilder.createFormBuilder() + .addLabeledComponent("Start URL", urlField, 1, false) + .addComponentFillVertically(JBLabel( + "Embedded browser will open this URL when the tool window is shown.
" + + "The target site must allow embedding (no strict X-Frame-Options / CSP frame-ancestors).
" + + "e.g. http://127.0.0.1:8888", UIUtil.ComponentStyle.SMALL, UIUtil.FontColor.NORMAL), 1) + .panel + + init { + urlField.columns = 40 + urlField.emptyText.text = "http://127.0.0.1:8888" + } + + fun isModified(settings: SettingsState): Boolean = + urlField.text != settings.startUrl + + fun apply(settings: SettingsState) { + settings.startUrl = urlField.text.trim() + } + + fun reset(settings: SettingsState) { + urlField.text = settings.startUrl + } + + companion object { + private val LOG = Logger.getInstance(HarmnessBrowserConfigurablePanel::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt b/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt new file mode 100644 index 0000000..df39607 --- /dev/null +++ b/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt @@ -0,0 +1,235 @@ +package com.harmness.toolwindow + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefJSQuery +import org.cef.browser.CefBrowser +import org.cef.handler.CefDisplayHandlerAdapter +import org.cef.handler.CefLifeSpanHandlerAdapter +import org.cef.handler.CefLoadHandlerAdapter +import java.awt.BorderLayout +import java.awt.Desktop +import java.awt.FlowLayout +import javax.swing.AbstractAction +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTextField + +/** + * 嵌入浏览器面板。 + * + * 职责: + * - 使用 JBCefBrowser 渲染用户配置的起始地址(例如本地 Harness 地址) + * - 工具栏:地址栏、前进/后退、刷新、设置入口(快捷跳转到 IDE Settings 的插件配置页) + * - 通过 [JBCefJSQuery] 提供 JS ↔ 插件代码双向桥 + * - 拦截并处理 _blank 弹窗(默认吞掉,可选交给系统浏览器) + * + * 由于 IntelliJ Platform 官方插件模型目前不允许在 JCEF 浏览器内部直接调用 + * "打开当前项目文件" 之类的 API(无 DOM 访问权限),插件通过注入到页面中的 + * 桥接函数 [JS_BRIDGE_NAME] 让网页(如 Harness 前端)发起请求, + * 再由本类把结果返回给页面。 + */ +class HarmnessBrowserPanel( + private val project: com.intellij.openapi.project.Project, + parentDisposable: Disposable +) : Disposable, JBPanel(BorderLayout()) { + + private val settings = SettingsState.getInstance() + + private lateinit var browser: JBCefBrowser + private lateinit var addressField: JTextField + private lateinit var query: JBCefJSQuery + private val loadEnded = java.util.concurrent.atomic.AtomicBoolean(false) + + init { + Disposer.register(parentDisposable, this) + + if (!JBCefApp.isSupported()) { + add(JBLabel( + "JCEF (Java Chromium Embedded Framework) is not supported in this IDE instance.\n" + + "Please use an IDE build that bundles JCEF, or a compatible JDK.", 0), + BorderLayout.CENTER) + return + } + + browser = JBCefBrowser() + + // 布局:顶部工具栏 + 浏览器 + add(createToolbar(), BorderLayout.NORTH) + add(browser.component, BorderLayout.CENTER) + + // 设置弹窗处理 + setupPopupHandling() + // 设置 JS 桥 + setupBridge() + + // 初始加载 + loadCurrentUrl() + } + + // ------------------------------------------------------------------ + // 工具栏 + // ------------------------------------------------------------------ + + private fun createToolbar(): JComponent { + val cefBrowser = browser.cefBrowser + + val back = JButton(AbstractAction("←") { + if (cefBrowser.canGoBack()) cefBrowser.goBack() + }) + val forward = JButton(AbstractAction("→") { + if (cefBrowser.canGoForward()) cefBrowser.goForward() + }) + val reload = JButton(AbstractAction("⟳") { cefBrowser.reload() }) + val settingsBtn = JButton(AbstractAction("⚙") { openSettings() }) + val externalBtn = JButton(AbstractAction("⧉") { openInExternalBrowser() }) + + addressField = JTextField(settings.startUrl) + addressField.toolTipText = "当前页面地址(可手动输入后回车跳转)" + addressField.addActionListener { navigate(addressField.text) } + + val toolbar = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2)) + toolbar.add(back) + toolbar.add(forward) + toolbar.add(reload) + toolbar.add(addressField) + toolbar.add(settingsBtn) + toolbar.add(externalBtn) + return toolbar + } + + private fun navigate(url: String) { + val fixedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url" + browser.loadURL(fixedUrl) + } + + private fun openSettings() { + val app = com.intellij.openapi.application.ApplicationManager.getApplication() + val show = Runnable { + com.intellij.openapi.options.ShowSettingsUtil.getInstance() + .showSettingsDialog(project, "Harmness Browser") + } + if (app.isDispatchThread) show.run() else app.invokeLater(show) + } + + private fun openInExternalBrowser() { + val url = addressField.text.ifBlank { settings.startUrl } + if (Desktop.isDesktopSupported()) { + try { + Desktop.getDesktop().browse(java.net.URI(url)) + } catch (e: Exception) { + BrowserUtil.browse(url) + } + } else { + BrowserUtil.browse(url) + } + } + + // ------------------------------------------------------------------ + // 弹窗与外部链接策略 + // ------------------------------------------------------------------ + + private fun setupPopupHandling() { + browser.jbCefClient.addLifeSpanHandler(object : CefLifeSpanHandlerAdapter() { + override fun onBeforePopup( + browser: CefBrowser, + frame: org.cef.browser.CefFrame, + targetUrl: String, + targetFrameName: String + ): Boolean { + // 拒绝在 IDE 内打开新窗口;改由外部系统浏览器打开 + if (targetUrl.isNotBlank()) { + BrowserUtil.browse(targetUrl) + } + return true // 取消弹窗 + } + }, browser.cefBrowser) + + browser.jbCefClient.addDisplayHandler(object : CefDisplayHandlerAdapter() { + override fun onTitleChange(browser: CefBrowser, title: String?) { + // 可用来同步工具窗口标题,暂时留空 + } + }, browser.cefBrowser) + } + + // ------------------------------------------------------------------ + // JS ↔ Java 桥 + // ------------------------------------------------------------------ + + private fun setupBridge() { + query = JBCefJSQuery.create(browser) + query.addHandler { request -> + val r = request ?: "" + LOG.debug("JS bridge request: $r") + when { + r == "ping" -> JBCefJSQuery.Response("pong") + r == "getUrl" -> JBCefJSQuery.Response(browser.cefBrowser.url) + r.startsWith("openExternal:") -> { + val url = r.removePrefix("openExternal:") + BrowserUtil.browse(url) + JBCefJSQuery.Response("ok") + } + else -> JBCefJSQuery.Response("unknown request: $r", 1, "unknown") + } + } + + // 注入桥接函数:页面加载完成后注入到 window.harmnessBridge + browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { + override fun onLoadEnd(browser: CefBrowser, frame: org.cef.browser.CefFrame, httpStatusCode: Int) { + if (frame.isMain && loadEnded.compareAndSet(false, true)) { + val jsCode = """ + window.$JS_BRIDGE_NAME = function(payload) { + var callbacks = payload && payload.callbacks; + ${query.inject( + "payload.query", + "function(response) { if (callbacks && callbacks.onSuccess) callbacks.onSuccess(response); }", + "function(error_code, error_message) { if (callbacks && callbacks.onFailure) callbacks.onFailure(error_code, error_message); }" + )}; + }; + // 通知页面桥接已就绪 + if (window.__HARMNESS_READY__) window.__HARMNESS_READY__(); + """.trimIndent() + browser.executeJavaScript(jsCode, browser.url, 0) + } + } + }, browser.cefBrowser) + } + + // ------------------------------------------------------------------ + // 对外方法(供 ToolWindow 调用) + // ------------------------------------------------------------------ + + fun loadCurrentUrl() { + if (!::browser.isInitialized) return + val url = settings.startUrl + addressField.text = url + browser.loadURL(url) + loadEnded.set(false) + } + + fun reloadFromSettings() { + loadCurrentUrl() + } + + override fun dispose() { + loadEnded.set(false) + if (::query.isInitialized) { + query.dispose() + } + if (::browser.isInitialized) { + Disposer.dispose(browser) + } + } + + companion object { + private val LOG = Logger.getInstance(HarmnessBrowserPanel::class.java) + const val JS_BRIDGE_NAME = "harmnessBridge" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/harmness/toolwindow/HarmnessSettingsConfigurable.kt b/src/main/kotlin/com/harmness/toolwindow/HarmnessSettingsConfigurable.kt new file mode 100644 index 0000000..5dbba95 --- /dev/null +++ b/src/main/kotlin/com/harmness/toolwindow/HarmnessSettingsConfigurable.kt @@ -0,0 +1,42 @@ +package com.harmness.toolwindow + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.Configurable.WithEpName +import javax.swing.JComponent + +/** + * Settings configurable registered in plugin.xml. + * Shows the HarmnessBrowserConfigurablePanel under Tools → Harmness Browser. + */ +class HarmnessSettingsConfigurable : Configurable, WithEpName { + + private var panel: HarmnessBrowserConfigurablePanel? = null + + override fun getDisplayName(): String = "Harmness Browser" + + override fun getHelpTopic(): String? = null + + override fun createComponent(): JComponent { + panel = HarmnessBrowserConfigurablePanel() + return panel!!.panel + } + + override fun isModified(): Boolean = panel?.isModified(SettingsState.getInstance()) ?: false + + override fun apply() { + panel?.apply(SettingsState.getInstance()) + } + + override fun reset() { + panel?.reset(SettingsState.getInstance()) + } + + override fun disposeUIResources() { + panel = null + } + + companion object { + private val LOG = Logger.getInstance(HarmnessSettingsConfigurable::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/harmness/toolwindow/HarmnessToolWindowFactory.kt b/src/main/kotlin/com/harmness/toolwindow/HarmnessToolWindowFactory.kt new file mode 100644 index 0000000..0088bb6 --- /dev/null +++ b/src/main/kotlin/com/harmness/toolwindow/HarmnessToolWindowFactory.kt @@ -0,0 +1,70 @@ +package com.harmness.toolwindow + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.content.ContentFactory +import javax.swing.JComponent + +/** + * 本插件的工具窗口工厂。 + * + * 工具窗口是 IntelliJ 侧边栏的区域(如 Project、Terminal、Version Control), + * 由 IntelliJ 自动管理生命周期。我们在此创建一个自定义工具窗口 "Harmness Browser", + * 其内容就是 [HarmnessBrowserPanel]。 + * + * plugin.xml 中通过扩展点注册本类: + * ``` + * + * ``` + */ +class HarmnessToolWindowFactory : ToolWindowFactory, DumbAware { + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + LOG.info("Creating Harmness Browser tool window for project: ${project.name}") + + // 检查 JCEF 支持 + if (!isJcefSupported()) { + LOG.warn("JCEF is not supported in this IDE instance. The Harmness Browser tool window will show an error message.") + } + + val panel = HarmnessBrowserPanel(project, toolWindow.disposable) + + val content = ContentFactory.getInstance().createContent(panel, "Browser", false) + toolWindow.contentManager.addContent(content) + + // 注册工具窗口可见性监听器,用于通知 WindowFocusService + toolWindow.addContentManagerListener(object : com.intellij.ui.content.ContentManagerListener { + override fun selectionChanged(selectionChange: com.intellij.ui.content.ContentManagerEvent) { + val isActive = selectionChange.manager.selectedContent == selectionChange.content + WindowFocusService.getInstance().setActive(isActive) + if (isActive) { + // 当工具窗口被选中时,触发面板刷新 + panel.loadCurrentUrl() + } + } + }) + + // 注册工具窗口可见性监听器(窗口获得/失去焦点) + toolWindow.addVisibleListener { visible -> + WindowFocusService.getInstance().setActive(visible) + } + } + + private fun isJcefSupported(): Boolean { + return try { + val clz = Class.forName("com.intellij.ui.jcef.JBCefApp") + val method = clz.getMethod("isSupported") + method.invoke(null) as Boolean + } catch (e: Exception) { + false + } + } + + companion object { + private val LOG = Logger.getInstance(HarmnessToolWindowFactory::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/harmness/toolwindow/SettingsState.kt b/src/main/kotlin/com/harmness/toolwindow/SettingsState.kt new file mode 100644 index 0000000..a972cad --- /dev/null +++ b/src/main/kotlin/com/harmness/toolwindow/SettingsState.kt @@ -0,0 +1,44 @@ +package com.harmness.toolwindow + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.diagnostic.Logger + +/** + * 插件设置:目前仅保存一个「浏览器起始地址」。 + * + * 该地址必须是一个允许被嵌入的 Web 应用地址(例如本地 Harness 服务地址), + * 并且需要在页面上开启允许被 iframe/嵌入浏览(X-Frame-Options / CSP frame-ancestors) + * 或者由 Harness 提供专门的无 frame 限制入口。 + */ +@State(name = "HarmnessToolwindowSettings", storages = [Storage("harmness-toolwindow.xml")]) +class SettingsState : PersistentStateComponent { + + data class State( + var startUrl: String = "http://127.0.0.1:8888", + ) + + private var myState = State() + private val logger = Logger.getInstance(SettingsState::class.java) + + override fun getState(): State = myState + + override fun loadState(state: State) { + myState = state + logger.info("Loaded settings: ${state.startUrl}") + } + + var startUrl: String + get() = myState.startUrl.ifBlank { DEFAULT_URL } + set(value) { + myState = myState.copy(startUrl = value.trim()) + } + + companion object { + const val DEFAULT_URL = "http://127.0.0.1:8888" + + fun getInstance(): SettingsState = + com.intellij.openapi.components.ServiceManager.getService(SettingsState::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/harmness/toolwindow/WindowFocusService.kt b/src/main/kotlin/com/harmness/toolwindow/WindowFocusService.kt new file mode 100644 index 0000000..85c9d61 --- /dev/null +++ b/src/main/kotlin/com/harmness/toolwindow/WindowFocusService.kt @@ -0,0 +1,48 @@ +package com.harmness.toolwindow + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger + +/** + * 全局单例服务,负责把「工具窗口是否可见/激活」的实时状态广播给浏览器面板。 + * + * 之所以需要它:JCEF 浏览器的 JavaScript 侧想知道窗口何时被聚焦/失焦, + * 以便暂停或恢复渲染(例如 Harness 界面在后台时降低 CPU 占用)。 + * 由于 ToolWindow 在内容区与焦点事件里提供的上下文有限,我们用这个轻量级 + * 事件总线在 ToolWindowManager 和 JBCefBrowser 之间传递状态。 + */ +@Service(Service.Level.APP) +class WindowFocusService { + private val logger = Logger.getInstance(WindowFocusService::class.java) + + @Volatile + var active: Boolean = false + private set + + private val listeners = mutableListOf<(Boolean) -> Unit>() + + @Synchronized + fun addListener(listener: (Boolean) -> Unit) { + listeners.add(listener) + // 注册时立即补发一次当前状态,避免面板错过初始状态 + listener(active) + } + + @Synchronized + fun removeListener(listener: (Boolean) -> Unit) { + listeners.remove(listener) + } + + @Synchronized + fun setActive(value: Boolean) { + if (active == value) return + active = value + logger.debug("Window focus changed: $value") + listeners.toList().forEach { it(value) } + } + + companion object { + fun getInstance(): WindowFocusService = service() + } +} \ No newline at end of file diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..159a5cf --- /dev/null +++ b/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,47 @@ + + com.harmness.toolwindow + Harmness Browser + harmness + + + Embed a configurable browser panel (e.g. a local Harmness instance) directly inside + JetBrains IDE sidebars. Enables seamless AI-assisted coding inside PyCharm / IntelliJ + IDEA without switching to a separate browser window. + + + + + + + com.intellij.modules.platform + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/icons/harmness.svg b/src/main/resources/icons/harmness.svg new file mode 100644 index 0000000..03e2c7f --- /dev/null +++ b/src/main/resources/icons/harmness.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7fb74a1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Harmness Embedded + + +
+ + + \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..9744810 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "harmness-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..3168b7d --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' +import { callPlugin, initBridge, isBridgeReady, openInExternalBrowser } from './bridge' + +export default function App() { + const [ready, setReady] = useState(false) + const [pong, setPong] = useState('') + const [status, setStatus] = useState('detecting…') + + useEffect(() => { + const dispose = initBridge(() => { + setReady(true) + setStatus('bridge ready') + }) + return dispose + }, []) + + const runPing = async () => { + try { + setPong(await callPlugin('ping')) + } catch (e) { + setPong(`error: ${(e as Error).message}`) + } + } + + return ( +
+

Harmness Embedded

+

+ Bridge: {isBridgeReady() ? 'ready' : 'not ready'} ({status}) +

+ +
+ + +
+ + {pong &&
{pong}
} + +

+ 这个页面由 web/ 下的 Vite + React + TypeScript 脚手架生成。 + 开发时在 web/ 运行 npm run dev(端口 8888), + 然后在插件 Settings 中把起始地址配置为 http://127.0.0.1:8888。 +

+
+ ) +} \ No newline at end of file diff --git a/web/src/bridge.ts b/web/src/bridge.ts new file mode 100644 index 0000000..19df2d5 --- /dev/null +++ b/web/src/bridge.ts @@ -0,0 +1,105 @@ +/** + * 与 JetBrains 插件(JCEF 嵌入浏览器)之间的 JS 桥接类型定义。 + * + * 插件在页面加载完成后会向 `window` 注入全局函数 `harmnessBridge`(见 + * HarmnessBrowserPanel.kt 的 JS_BRIDGE_NAME)。网页侧通过它调用插件代码, + * 典型用途: + * - 打开系统浏览器访问外部链接 + * - 获取当前页面地址 + * - 未来:调用「打开编辑器」「插入文本」等 IDE 能力(需要插件侧扩展桥接协议) + */ + +export interface BridgeCallbacks { + onSuccess?: (response: string) => void + onFailure?: (errorCode: number, errorMessage: string) => void +} + +export interface BridgePayload { + query: string + callbacks?: BridgeCallbacks +} + +/** 插件注入的全局桥接函数签名 */ +export type HarmnessBridgeFn = (payload: BridgePayload) => void + +declare global { + interface Window { + /** 由插件注入:页面加载完成后可用 */ + harmnessBridge?: HarmnessBridgeFn + } +} + +/** 单一挂载点,避免页面多次初始化时反复覆盖监听器 */ +let initialized = false + +/** 桥接是否就绪 */ +export function isBridgeReady(): boolean { + return typeof window.harmnessBridge === 'function' +} + +/** + * 初始化桥接:监听 `__HARMNESS_READY__`(插件注入桥后回调), + * 之后即可调用 [callPlugin]。 + */ +export function initBridge(onReady?: () => void): () => void { + if (initialized) return () => {} + initialized = true + + const ready = () => { + onReady?.() + } + + if (isBridgeReady()) { + onReady?.() + } + + // 插件在注入桥后调用 window.__HARMNESS_READY__() + const prev = (window as any).__HARMNESS_READY__ + ;(window as any).__HARMNESS_READY__ = () => { + if (typeof prev === 'function') prev() + ready() + } + + return () => { + initialized = false + delete (window as any).__HARMNESS_READY__ + } +} + +/** + * 调用插件侧方法。若桥未就绪则返回 rejected Promise。 + * + * @param query 桥协议字符串,例如 "openExternal:https://example.com" + * @param timeoutMs 超时(毫秒),默认 5000 + */ +export function callPlugin(query: string, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const bridge = window.harmnessBridge + if (typeof bridge !== 'function') { + reject(new Error('harmnessBridge is not available yet')) + return + } + + const timer = window.setTimeout(() => { + reject(new Error(`Bridge call timed out after ${timeoutMs}ms: ${query}`)) + }, timeoutMs) + + bridge({ + query, + callbacks: { + onSuccess: (response) => { + window.clearTimeout(timer) + resolve(response) + }, + onFailure: (code, message) => { + window.clearTimeout(timer) + reject(new Error(`Bridge error ${code}: ${message}`)) + }, + }, + }) + }) +} + +// 便捷方法 +export const openInExternalBrowser = (url: string) => callPlugin(`openExternal:${url}`) +export const ping = () => callPlugin('ping') \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..aa16887 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,67 @@ +:root { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: #1f2328; + background: #f6f8fa; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 24px; +} + +.app { + max-width: 720px; + margin: 0 auto; +} + +h1 { + font-size: 22px; + margin: 0 0 8px; +} + +.status { + color: #57606a; + margin: 0 0 16px; +} + +.controls { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +button { + padding: 6px 14px; + border: 1px solid #d0d7de; + border-radius: 6px; + background: #fff; + cursor: pointer; +} + +button:hover:not(:disabled) { + background: #f3f4f6; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.result { + background: #0d1117; + color: #7ee787; + padding: 12px; + border-radius: 6px; + overflow-x: auto; +} + +.hint { + font-size: 13px; + color: #57606a; + border-top: 1px solid #d8dee4; + padding-top: 12px; +} \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..4f9ad12 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) \ No newline at end of file diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..ecf0f61 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist" + }, + "include": ["src"] +} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..904af74 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + // 默认端口,与插件 Settings 默认地址一致 + port: 8888, + strictPort: false, + }, + build: { + // 输出到插件 resources 目录(可选:也可用 Vite dev server 做开发) + outDir: 'dist', + }, +}) \ No newline at end of file