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,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()
}
}