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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user