Defensive panel: toolbar/settings independent of JCEF, lazy browser init, error card, drop WindowFocusService
This commit is contained in:
@@ -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:工具栏(← → ⟳ 地址栏 ⚙)
|
||||
* - CENTER:CardLayout,包含三张卡:
|
||||
* - "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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user