Defensive panel: toolbar/settings independent of JCEF, lazy browser init, error card, drop WindowFocusService

This commit is contained in:
harmness
2026-09-10 20:17:17 +08:00
parent b3fee364e0
commit e51c046007
5 changed files with 242 additions and 191 deletions
@@ -6,31 +6,41 @@ 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.components.JBScrollPane
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.CardLayout
import java.awt.Desktop
import java.awt.FlowLayout
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import java.awt.Insets
import java.awt.event.ActionEvent
import javax.swing.AbstractAction
import javax.swing.BorderFactory
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JTextArea
import javax.swing.JTextField
import javax.swing.SwingUtilities
/**
* 嵌入浏览器面板。
* Harmness 嵌入浏览器面板。
*
* 两种视图模式,通过右上角「⚙」图标切换
* - [Mode.BROWSER]:显示配置好的网页(JCEF
* - [Mode.SETTINGS]:表单,可修改「起始地址」,点保存后自动切回浏览器并加载新地址
* 布局
* - NORTH:工具栏(← → ⟳ 地址栏 ⚙
* - CENTERCardLayout,包含三张卡:
* - "settings":设置表单(起始地址 + 保存/取消)
* - "browser"JCEF 浏览器(懒初始化,失败时显示错误)
* - "error":初始化失败时的错误信息
*
* 关键设计:工具栏和设置表单**不依赖** JCEF。即使 JCEF 初始化失败,
* 界面仍然可用,错误信息会显示在中心区域,方便排错。
*/
class HarmnessBrowserPanel(
private val project: com.intellij.openapi.project.Project,
@@ -38,57 +48,33 @@ class HarmnessBrowserPanel(
) : Disposable, JBPanel<HarmnessBrowserPanel>(BorderLayout()) {
private val settings = SettingsState.getInstance()
private val logger = Logger.getInstance(HarmnessBrowserPanel::class.java)
private lateinit var browser: JBCefBrowser
private lateinit var addressField: JTextField
private lateinit var query: JBCefJSQuery
private val loadEnded = java.util.concurrent.atomic.AtomicBoolean(false)
// JCEF 浏览器(懒初始化)
private var browser: JBCefBrowser? = null
private var query: JBCefJSQuery? = null
private var mode = Mode.BROWSER
private var addressField: JTextField = JTextField()
private var settingsUrlField: JTextField = JTextField(30)
private enum class Mode { BROWSER, SETTINGS }
private val cardLayout = CardLayout()
private val center = JBPanel<JBPanel<*>>(cardLayout)
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)
} else {
browser = JBCefBrowser()
setupPopupHandling()
setupBridge()
// 顶部工具栏(右上角 ⚙)
add(createToolbar(), BorderLayout.NORTH)
// 初始为浏览器视图
showBrowserMode()
}
}
// ------------------------------------------------------------------
// 视图切换
// ------------------------------------------------------------------
private fun showBrowserMode() {
mode = Mode.BROWSER
removeAll()
// 工具栏(不依赖 JCEF,总是先建立)
add(createToolbar(), BorderLayout.NORTH)
add(browser.component, BorderLayout.CENTER)
revalidate()
repaint()
loadCurrentUrl()
}
add(center, BorderLayout.CENTER)
private fun showSettingsMode() {
mode = Mode.SETTINGS
removeAll()
add(createToolbar(), BorderLayout.NORTH)
add(createSettingsForm(), BorderLayout.CENTER)
revalidate()
repaint()
// 中心卡片
center.add(createStandbyCard(), CARD_STANDBY)
center.add(createSettingsCard(), CARD_SETTINGS)
center.add(createErrorCard(), CARD_ERROR)
cardLayout.show(center, CARD_STANDBY)
// 在窗口系统就绪后异步初始化浏览器,避免阻塞 EDT 或初始化失败拖垮整个面板
SwingUtilities.invokeLater { initBrowserSafely() }
}
// ------------------------------------------------------------------
@@ -96,28 +82,35 @@ class HarmnessBrowserPanel(
// ------------------------------------------------------------------
private fun createToolbar(): JComponent {
val cefBrowser = browser.cefBrowser
val back = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
if (cefBrowser.canGoBack()) cefBrowser.goBack()
val cef = browser?.cefBrowser
if (cef != null && cef.canGoBack()) cef.goBack()
}
})
val forward = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
if (cefBrowser.canGoForward()) cefBrowser.goForward()
val cef = browser?.cefBrowser
if (cef != null && cef.canGoForward()) cef.goForward()
}
})
val reload = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { cefBrowser.reload() }
override fun actionPerformed(e: ActionEvent) { browser?.cefBrowser?.reload() }
})
// 右上角设置图标(⚙),点击在两种模式间切换
// 右上角设置图标:在浏览器视图与设置视图间切换
val settingsBtn = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { toggleSettings() }
override fun actionPerformed(e: ActionEvent) {
if (cardLayout == null) return
if (currentCard == CARD_SETTINGS) {
showBrowser()
} else {
showSettings()
}
}
})
settingsBtn.toolTipText = if (mode == Mode.BROWSER) "设置" else "返回主界面"
settingsBtn.border = BorderFactory.createEmptyBorder(2, 6, 2, 6)
settingsBtn.toolTipText = "设置"
settingsBtn.border = BorderFactory.createEmptyBorder(2, 8, 2, 8)
addressField = JTextField(settings.startUrl)
addressField.toolTipText = "当前页面地址(可手动输入后回车跳转)"
@@ -132,34 +125,110 @@ class HarmnessBrowserPanel(
return toolbar
}
private fun toggleSettings() {
if (mode == Mode.BROWSER) {
showSettingsMode()
// ------------------------------------------------------------------
// 卡片管理与视图切换
// ------------------------------------------------------------------
private var currentCard: String = CARD_STANDBY
private fun showCard(name: String) {
currentCard = name
cardLayout.show(center, name)
}
private fun showSettings() {
settingsUrlField.text = settings.startUrl
showCard(CARD_SETTINGS)
}
private fun showBrowser() {
if (browser == null) {
// 浏览器还没建好:尝试初始化;失败显示错误
initBrowserSafely()
} else {
// 从设置页切回主界面时重新加载
showBrowserMode()
showCard(CARD_BROWSER)
loadCurrentUrl()
}
}
private fun navigate(url: String) {
val fixedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
browser.loadURL(fixedUrl)
loadEnded.set(false)
// ------------------------------------------------------------------
// JCEF 浏览器初始化(安全包装)
// ------------------------------------------------------------------
private fun initBrowserSafely() {
try {
if (browser != null) return
if (!JBCefApp.isSupported()) {
showError("JCEF (Java Chromium Embedded Framework) 在当前 IDE 中不受支持。\n" +
"请使用官方发行版 IDE(内置 JBR),或检查 IDE 是否禁用了 JCEF。")
return
}
val b = JBCefBrowser()
browser = b
setupPopupHandling(b)
setupBridge(b)
// 把浏览器组件放进 "browser" 卡片
center.add(b.component, CARD_BROWSER)
if (currentCard != CARD_SETTINGS) {
showCard(CARD_BROWSER)
}
loadCurrentUrl()
} catch (t: Throwable) {
logger.error("Failed to init JCEF browser", t)
showError(buildErrorMessage(t))
}
}
private fun buildErrorMessage(t: Throwable): String {
val sb = StringBuilder()
sb.append("JCEF 浏览器初始化失败:\n")
appendEx(sb, t)
return sb.toString()
}
private fun appendEx(sb: StringBuilder, t: Throwable, depth: Int = 0) {
if (depth > 4) return
sb.append(t.javaClass.simpleName).append(": ").append(t.message).append('\n')
for (el in t.stackTrace.take(6)) {
sb.append(" at ").append(el).append('\n')
}
t.cause?.let { appendEx(sb, it, depth + 1) }
}
private fun showError(message: String) {
val area = center.getClientProperty(ERROR_AREA_KEY) as? JTextArea
?: run {
val a = JTextArea()
center.putClientProperty(ERROR_AREA_KEY, a)
a
}
area.text = message
showCard(CARD_ERROR)
}
// ------------------------------------------------------------------
// 设置表单
// 卡片内容
// ------------------------------------------------------------------
private fun createSettingsForm(): JComponent {
val panel = JBPanel<JBPanel<*>>()
panel.layout = java.awt.GridBagLayout()
val gbc = java.awt.GridBagConstraints()
gbc.insets = java.awt.Insets(6, 8, 6, 8)
gbc.fill = java.awt.GridBagConstraints.HORIZONTAL
gbc.anchor = java.awt.GridBagConstraints.NORTH
private fun createStandbyCard(): JComponent {
val p = JBPanel<JBPanel<*>>(BorderLayout())
val label = JBLabel("正在初始化浏览器…", 0)
p.add(label, BorderLayout.CENTER)
return p
}
val title = JBLabel("<html><h2>Settings</h2></html>")
/** 设置表单:起始地址 + 保存/取消 */
private fun createSettingsCard(): JComponent {
val panel = JBPanel<JBPanel<*>>()
panel.layout = GridBagLayout()
val gbc = GridBagConstraints()
gbc.insets = Insets(8, 10, 8, 10)
gbc.fill = GridBagConstraints.HORIZONTAL
gbc.anchor = GridBagConstraints.NORTHWEST
val title = JBLabel("<html><h2 style='margin:0'>设置</h2></html>")
gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2
panel.add(title, gbc)
gbc.gridwidth = 1
@@ -168,30 +237,30 @@ class HarmnessBrowserPanel(
gbc.gridx = 0; gbc.gridy = 1
panel.add(label, gbc)
val urlField = JTextField(settings.startUrl, 30)
settingsUrlField = JTextField(settings.startUrl, 28)
gbc.gridx = 1; gbc.gridy = 1
panel.add(urlField, gbc)
panel.add(settingsUrlField, gbc)
val hint = JBLabel("<html><small>例如:http://127.0.0.1:8899 (本地 Harness 服务地址)</small></html>")
val hint = JBLabel("<html><small>例如:http://127.0.0.1:8899 (本地 Harmness 服务地址)<br>" +
"目标站点需允许被嵌入(不要有 X-Frame-Options / CSP frame-ancestors 限制)</small></html>")
gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 2
panel.add(hint, gbc)
gbc.gridwidth = 1
val saveBtn = JButton("保存")
saveBtn.addActionListener {
settings.startUrl = urlField.text.trim()
settings.startUrl = settingsUrlField.text.trim()
saveBtn.text = "已保存 ✓"
SwingUtilities.invokeLater {
Thread.sleep(400)
try { Thread.sleep(500) } catch (_: InterruptedException) {}
saveBtn.text = "保存"
}
showBrowserMode()
// 保存后切回浏览器视图并加载新地址
showBrowser()
}
val cancelBtn = JButton("取消")
cancelBtn.addActionListener {
showBrowserMode()
}
cancelBtn.addActionListener { showBrowser() }
val btnPanel = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0))
btnPanel.add(saveBtn)
@@ -206,45 +275,55 @@ class HarmnessBrowserPanel(
return panel
}
private fun createErrorCard(): JComponent {
val area = JTextArea()
area.isEditable = false
area.font = area.font.deriveFont(12f)
area.lineWrap = true
area.wrapStyleWord = true
center.putClientProperty(ERROR_AREA_KEY, area)
return JBScrollPane(area)
}
// ------------------------------------------------------------------
// 弹窗与外部链接策略
// 浏览器行为
// ------------------------------------------------------------------
private fun setupPopupHandling() {
browser.jbCefClient.addLifeSpanHandler(object : CefLifeSpanHandlerAdapter() {
private fun navigate(url: String) {
val b = browser ?: return
val fixedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
b.loadURL(fixedUrl)
}
private fun setupPopupHandling(b: JBCefBrowser) {
b.jbCefClient.addLifeSpanHandler(object : CefLifeSpanHandlerAdapter() {
override fun onBeforePopup(
browser: CefBrowser,
cefBrowser: org.cef.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)
}, b.cefBrowser)
}
// ------------------------------------------------------------------
// JS ↔ Java 桥
// ------------------------------------------------------------------
private fun setupBridge() {
query = JBCefJSQuery.create(browser)
query.addHandler { request ->
private fun setupBridge(b: JBCefBrowser) {
val q = JBCefJSQuery.create(b)
query = q
q.addHandler { request ->
val r = request ?: ""
LOG.debug("JS bridge request: $r")
logger.debug("JS bridge request: $r")
when {
r == "ping" -> JBCefJSQuery.Response("pong")
r == "getUrl" -> JBCefJSQuery.Response(browser.cefBrowser.url)
r == "getUrl" -> JBCefJSQuery.Response(b.cefBrowser.url)
r.startsWith("openExternal:") -> {
val url = r.removePrefix("openExternal:")
BrowserUtil.browse(url)
@@ -254,52 +333,65 @@ class HarmnessBrowserPanel(
}
}
// 注入桥接函数:页面加载完成后注入到 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)) {
// 注入桥接函数:主 frame 加载完成后
b.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() {
override fun onLoadEnd(cefBrowser: org.cef.browser.CefBrowser, frame: org.cef.browser.CefFrame, httpStatusCode: Int) {
if (frame.isMain) {
val jsCode = """
window.$JS_BRIDGE_NAME = function(payload) {
var callbacks = payload && payload.callbacks;
${query.inject(
${q.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)
b.cefBrowser.executeJavaScript(jsCode, cefBrowser.url, 0)
}
}
}, browser.cefBrowser)
}, b.cefBrowser)
}
// ------------------------------------------------------------------
// 对外方法(供 ToolWindow 调用)
// 对外方法
// ------------------------------------------------------------------
/** 加载当前设置中的起始地址(初次打开 / 设置变更后调用) */
fun loadCurrentUrl() {
if (!::browser.isInitialized) return
val b = browser ?: return
val url = settings.startUrl
addressField.text = url
browser.loadURL(url)
loadEnded.set(false)
b.loadURL(url)
}
/** 工具窗口被选中时由工厂回调:若浏览器存在则刷新,否则尝试初始化 */
fun onToolWindowSelected() {
if (browser == null) {
initBrowserSafely()
} else {
loadCurrentUrl()
}
}
override fun dispose() {
loadEnded.set(false)
if (::query.isInitialized) {
query.dispose()
}
if (::browser.isInitialized) {
Disposer.dispose(browser)
query?.dispose()
try {
browser?.let { Disposer.dispose(it) }
} catch (t: Throwable) {
logger.warn("dispose browser failed", t)
}
browser = null
query = null
}
companion object {
private val LOG = Logger.getInstance(HarmnessBrowserPanel::class.java)
private const val CARD_BROWSER = "browser"
private const val CARD_SETTINGS = "settings"
private const val CARD_ERROR = "error"
private const val CARD_STANDBY = "standby"
private const val ERROR_AREA_KEY = "harmness.errorArea"
const val JS_BRIDGE_NAME = "harmnessBridge"
}
}
@@ -5,34 +5,46 @@ 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.ui.components.JBLabel
import com.intellij.ui.components.JBPanel
import com.intellij.ui.content.ContentFactory
import com.intellij.ui.content.ContentManagerEvent
import com.intellij.ui.content.ContentManagerListener
import java.awt.BorderLayout
/**
* 本插件的工具窗口工厂。
* 工具窗口工厂:创建侧边栏 "Harmness Browser"
*
* 工具窗口是 IntelliJ 侧边栏的区域(如 Project、Terminal、Version Control
* 由 IntelliJ 自动管理生命周期。我们在此创建一个自定义工具窗口 "Harmness Browser"
* 其内容就是 [HarmnessBrowserPanel]。
* 采用防御式创建:面板构造或 JCEF 初始化失败时不会让整个工具窗口空白
* 而是显示具体错误信息,便于排查。
*/
class HarmnessToolWindowFactory : ToolWindowFactory, DumbAware {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
LOG.info("Creating Harmness Browser tool window for project: ${project.name}")
val panel = HarmnessBrowserPanel(project, toolWindow.disposable)
// 面板构造抛异常时,用一个错误提示面板代替,避免 IDE 显示空白内容
val panel = try {
HarmnessBrowserPanel(project, toolWindow.disposable)
} catch (t: Throwable) {
LOG.error("Failed to create HarmnessBrowserPanel", t)
val errPanel = JBPanel<JBPanel<*>>(BorderLayout())
val msg = buildString {
append("创建 Harmness 面板失败:\n")
append(t.javaClass.simpleName).append(": ").append(t.message ?: "no message")
}
errPanel.add(JBLabel(msg), BorderLayout.CENTER)
errPanel
}
val content = ContentFactory.getInstance().createContent(panel, "Browser", false)
toolWindow.contentManager.addContent(content)
// 当工具窗口被选中时刷新
val contentMgr = toolWindow.contentManager
contentMgr.addContentManagerListener(object : com.intellij.ui.content.ContentManagerListener {
override fun selectionChanged(event: com.intellij.ui.content.ContentManagerEvent) {
val isActive = event.content == contentMgr.selectedContent
WindowFocusService.getInstance().setActive(isActive)
if (isActive) {
panel.loadCurrentUrl()
}
toolWindow.contentManager.addContentManagerListener(object : ContentManagerListener {
override fun selectionChanged(event: ContentManagerEvent) {
if (event.content != toolWindow.contentManager.selectedContent) return
runCatching { (panel as? HarmnessBrowserPanel)?.onToolWindowSelected() }
.onFailure { LOG.warn("selectionChanged handler failed", it) }
}
})
}
@@ -2,13 +2,15 @@ package com.harmness.toolwindow
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.ServiceManager
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
/**
* 插件设置:仅保存「浏览器起始地址」。
*
* 通过 @Service 注解注册(无需在 plugin.xml 中重复声明)。
*/
@State(name = "HarmnessToolwindowSettings", storages = [Storage("harmness-toolwindow.xml")])
@Service(Service.Level.APP)
@@ -38,7 +40,6 @@ class SettingsState : PersistentStateComponent<SettingsState.State> {
// 与 DSH Web GUI 默认端口一致
const val DEFAULT_URL = "http://127.0.0.1:8899"
fun getInstance(): SettingsState =
ServiceManager.getService(SettingsState::class.java)
fun getInstance(): SettingsState = service()
}
}
@@ -1,48 +0,0 @@
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()
}
}
-6
View File
@@ -20,11 +20,5 @@
factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"
anchor="right"
/>
<applicationService
serviceImplementation="com.harmness.toolwindow.SettingsState"/>
<applicationService
serviceImplementation="com.harmness.toolwindow.WindowFocusService"/>
</extensions>
</idea-plugin>