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
@@ -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"
}
}