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
This commit is contained in:
harmness
2026-09-10 13:27:43 +08:00
commit cbe41e4f4f
23 changed files with 1125 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Gradle
.gradle/
build/
# IDE
.idea/
*.iml
.vscode/
# Node
node_modules/
web/dist/
# OS
.DS_Store
+195
View File
@@ -0,0 +1,195 @@
# Harmness Browser — JetBrains 插件
在 JetBrains IDEIntelliJ 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(约 12GB),请保持网络畅通。
### 3. 在 IDE 里运行/调试
在 IDEAUltimate 或 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 账号
访问 <https://account.jetbrains.com/login> 注册/登录(可用 Google / GitHub 账号)。
### 2. 申请插件仓库(只有一次)
1. 打开 <https://plugins.jetbrains.com/> 并登录。
2. 右上角头像 → **Developer Console**(或直接 <https://plugins.jetbrains.com/developer>)。
3. 点击 **Create New Plugin**(第一次需要填组织/开发者信息 + 同意
[Plugin Hosting Terms](https://plugins.jetbrains.com/docs/marketplace/plugin-hosting-terms.html))。
4. 创建后会得到一个 **Plugin ID**,把 `plugin.xml` 里的 `<id>`(当前为
`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. 进入 <https://plugins.jetbrains.com/developer>,选你的插件。
2. **Upload Plugin** 上传 zip。
3. 填写版本信息:
- **Version**:与 `build.gradle.kts``version` 一致(如 `0.1.0`
- **Since / Until build**:对应 `plugin.xml``242 / 243.*`
- **Changelog**:版本变更说明
- **Compatibility**:勾选目标 IDEIDEA Community/Ultimate 等)
4. 提交后,若勾选 **Publish**(或之后手动点 Publish),插件会进入审核,一般几分钟到数小时;
审核通过后即可在 Marketplace 搜索到。
> 之后每次发新版,重复第 3–4 步即可。也可用 CIGitHub 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。
- **下载**<https://plugins.jetbrains.com/plugin/xxxx>(你的插件主页)。
安装后在右侧栏点开 **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.22024.3。
- 后续可加:多地址书签、地址访问白名单(安全)、按 IDE 主题切换深/浅色、自动重连等。
+36
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=false
+26
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
rootProject.name = "harmness-toolwindow"
@@ -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(
"<html>Embedded browser will open this URL when the tool window is shown.<br>" +
"The target site must allow embedding (no strict <code>X-Frame-Options</code> / <code>CSP frame-ancestors</code>).<br>" +
"e.g. <b>http://127.0.0.1:8888</b></html>", 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)
}
}
@@ -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<HarmnessBrowserPanel>(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"
}
}
@@ -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)
}
}
@@ -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 中通过扩展点注册本类:
* ```
* <toolWindow id="Harmness Browser" ... factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"/>
* ```
*/
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)
}
}
@@ -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<SettingsState.State> {
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)
}
}
@@ -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()
}
}
+47
View File
@@ -0,0 +1,47 @@
<idea-plugin>
<id>com.harmness.toolwindow</id>
<name>Harmness Browser</name>
<vendor>harmness</vendor>
<description>
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.
</description>
<!--
IDE version compatibility:
sinceBuild / untilBuild 控制插件兼容的 IDE 版本范围。
这里要求 2024.2 及以上、2024.3.* 及以下。
如需支持更老版本,请参考 https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
-->
<idea-version since-build="242" until-build="243.*"/>
<!-- 依赖 platform -->
<depends>com.intellij.modules.platform</depends>
<!-- 工具窗口扩展点 -->
<extensions defaultExtensionNs="com.intellij">
<toolWindow
id="Harmness Browser"
icon="/icons/harmness.svg"
factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"
anchor="right"
condition="class#==com.intellij.ui.jcef.JBCefApp"
/>
<!-- 插件设置页:Settings | Tools | Harmness Browser -->
<applicationConfigurable
parent="tools"
instance="com.harmness.toolwindow.HarmnessSettingsConfigurable"
id="com.harmness.toolwindow.HarmnessSettingsConfigurable"
displayName="Harmness Browser"/>
<!-- 持久化组件 -->
<applicationService
serviceImplementation="com.harmness.toolwindow.SettingsState"/>
<applicationService
serviceImplementation="com.harmness.toolwindow.WindowFocusService"/>
</extensions>
</idea-plugin>
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="40" height="40" rx="8" fill="url(#g)"/>
<path d="M10 14h20v2H10zm0 5h20v2H10zm0 5h14v2H10z" fill="white"/>
<circle cx="30" cy="27" r="6" fill="white" opacity=".25"/>
<path d="M28 27l4-4" stroke="white" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 602 B

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Harmness Embedded</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22
View File
@@ -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"
}
}
+53
View File
@@ -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 (
<div className="app">
<h1>Harmness Embedded</h1>
<p className="status">
Bridge: <b>{isBridgeReady() ? 'ready' : 'not ready'}</b> ({status})
</p>
<div className="controls">
<button onClick={runPing} disabled={!ready}>
ping plugin
</button>
<button
onClick={() => openInExternalBrowser('https://www.jetbrains.com')}
disabled={!ready}
>
open external browser
</button>
</div>
{pong && <pre className="result">{pong}</pre>}
<p className="hint">
<code>web/</code> Vite + React + TypeScript
<code>web/</code> <code>npm run dev</code> 8888
Settings <code>http://127.0.0.1:8888</code>。
</p>
</div>
)
}
+105
View File
@@ -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<string> {
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')
+67
View File
@@ -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;
}
+10
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22
View File
@@ -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"]
}
+15
View File
@@ -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',
},
})