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.openapi.util.Disposer
import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBPanel import com.intellij.ui.components.JBPanel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.jcef.JBCefApp import com.intellij.ui.jcef.JBCefApp
import com.intellij.ui.jcef.JBCefBrowser import com.intellij.ui.jcef.JBCefBrowser
import com.intellij.ui.jcef.JBCefJSQuery 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.CefLifeSpanHandlerAdapter
import org.cef.handler.CefLoadHandlerAdapter import org.cef.handler.CefLoadHandlerAdapter
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.Desktop import java.awt.Desktop
import java.awt.FlowLayout import java.awt.FlowLayout
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import java.awt.Insets
import java.awt.event.ActionEvent import java.awt.event.ActionEvent
import javax.swing.AbstractAction import javax.swing.AbstractAction
import javax.swing.BorderFactory import javax.swing.BorderFactory
import javax.swing.JButton import javax.swing.JButton
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.JPanel import javax.swing.JPanel
import javax.swing.JTextArea
import javax.swing.JTextField import javax.swing.JTextField
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
/** /**
* 嵌入浏览器面板。 * Harmness 嵌入浏览器面板。
* *
* 两种视图模式,通过右上角「⚙」图标切换 * 布局
* - [Mode.BROWSER]:显示配置好的网页(JCEF * - NORTH:工具栏(← → ⟳ 地址栏 ⚙
* - [Mode.SETTINGS]:表单,可修改「起始地址」,点保存后自动切回浏览器并加载新地址 * - CENTERCardLayout,包含三张卡:
* - "settings":设置表单(起始地址 + 保存/取消)
* - "browser"JCEF 浏览器(懒初始化,失败时显示错误)
* - "error":初始化失败时的错误信息
*
* 关键设计:工具栏和设置表单**不依赖** JCEF。即使 JCEF 初始化失败,
* 界面仍然可用,错误信息会显示在中心区域,方便排错。
*/ */
class HarmnessBrowserPanel( class HarmnessBrowserPanel(
private val project: com.intellij.openapi.project.Project, private val project: com.intellij.openapi.project.Project,
@@ -38,57 +48,33 @@ class HarmnessBrowserPanel(
) : Disposable, JBPanel<HarmnessBrowserPanel>(BorderLayout()) { ) : Disposable, JBPanel<HarmnessBrowserPanel>(BorderLayout()) {
private val settings = SettingsState.getInstance() private val settings = SettingsState.getInstance()
private val logger = Logger.getInstance(HarmnessBrowserPanel::class.java)
private lateinit var browser: JBCefBrowser // JCEF 浏览器(懒初始化)
private lateinit var addressField: JTextField private var browser: JBCefBrowser? = null
private lateinit var query: JBCefJSQuery private var query: JBCefJSQuery? = null
private val loadEnded = java.util.concurrent.atomic.AtomicBoolean(false)
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 { init {
Disposer.register(parentDisposable, this) Disposer.register(parentDisposable, this)
if (!JBCefApp.isSupported()) { // 工具栏(不依赖 JCEF,总是先建立)
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) add(createToolbar(), BorderLayout.NORTH)
// 初始为浏览器视图 add(center, BorderLayout.CENTER)
showBrowserMode()
}
}
// ------------------------------------------------------------------ // 中心卡片
// 视图切换 center.add(createStandbyCard(), CARD_STANDBY)
// ------------------------------------------------------------------ center.add(createSettingsCard(), CARD_SETTINGS)
center.add(createErrorCard(), CARD_ERROR)
cardLayout.show(center, CARD_STANDBY)
private fun showBrowserMode() { // 在窗口系统就绪后异步初始化浏览器,避免阻塞 EDT 或初始化失败拖垮整个面板
mode = Mode.BROWSER SwingUtilities.invokeLater { initBrowserSafely() }
removeAll()
add(createToolbar(), BorderLayout.NORTH)
add(browser.component, BorderLayout.CENTER)
revalidate()
repaint()
loadCurrentUrl()
}
private fun showSettingsMode() {
mode = Mode.SETTINGS
removeAll()
add(createToolbar(), BorderLayout.NORTH)
add(createSettingsForm(), BorderLayout.CENTER)
revalidate()
repaint()
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -96,28 +82,35 @@ class HarmnessBrowserPanel(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
private fun createToolbar(): JComponent { private fun createToolbar(): JComponent {
val cefBrowser = browser.cefBrowser
val back = JButton(object : AbstractAction("") { val back = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { 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("") { val forward = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { 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("") { 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("") { 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.toolTipText = "设置"
settingsBtn.border = BorderFactory.createEmptyBorder(2, 6, 2, 6) settingsBtn.border = BorderFactory.createEmptyBorder(2, 8, 2, 8)
addressField = JTextField(settings.startUrl) addressField = JTextField(settings.startUrl)
addressField.toolTipText = "当前页面地址(可手动输入后回车跳转)" addressField.toolTipText = "当前页面地址(可手动输入后回车跳转)"
@@ -132,34 +125,110 @@ class HarmnessBrowserPanel(
return toolbar 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 { } else {
// 从设置页切回主界面时重新加载 showCard(CARD_BROWSER)
showBrowserMode() 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 createSettingsForm(): JComponent { 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 createStandbyCard(): JComponent {
val p = JBPanel<JBPanel<*>>(BorderLayout())
val label = JBLabel("正在初始化浏览器…", 0)
p.add(label, BorderLayout.CENTER)
return p
}
/** 设置表单:起始地址 + 保存/取消 */
private fun createSettingsCard(): JComponent {
val panel = JBPanel<JBPanel<*>>() val panel = JBPanel<JBPanel<*>>()
panel.layout = java.awt.GridBagLayout() panel.layout = GridBagLayout()
val gbc = java.awt.GridBagConstraints() val gbc = GridBagConstraints()
gbc.insets = java.awt.Insets(6, 8, 6, 8) gbc.insets = Insets(8, 10, 8, 10)
gbc.fill = java.awt.GridBagConstraints.HORIZONTAL gbc.fill = GridBagConstraints.HORIZONTAL
gbc.anchor = java.awt.GridBagConstraints.NORTH gbc.anchor = GridBagConstraints.NORTHWEST
val title = JBLabel("<html><h2>Settings</h2></html>") val title = JBLabel("<html><h2 style='margin:0'>设置</h2></html>")
gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2 gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2
panel.add(title, gbc) panel.add(title, gbc)
gbc.gridwidth = 1 gbc.gridwidth = 1
@@ -168,30 +237,30 @@ class HarmnessBrowserPanel(
gbc.gridx = 0; gbc.gridy = 1 gbc.gridx = 0; gbc.gridy = 1
panel.add(label, gbc) panel.add(label, gbc)
val urlField = JTextField(settings.startUrl, 30) settingsUrlField = JTextField(settings.startUrl, 28)
gbc.gridx = 1; gbc.gridy = 1 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 gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 2
panel.add(hint, gbc) panel.add(hint, gbc)
gbc.gridwidth = 1 gbc.gridwidth = 1
val saveBtn = JButton("保存") val saveBtn = JButton("保存")
saveBtn.addActionListener { saveBtn.addActionListener {
settings.startUrl = urlField.text.trim() settings.startUrl = settingsUrlField.text.trim()
saveBtn.text = "已保存 ✓" saveBtn.text = "已保存 ✓"
SwingUtilities.invokeLater { SwingUtilities.invokeLater {
Thread.sleep(400) try { Thread.sleep(500) } catch (_: InterruptedException) {}
saveBtn.text = "保存" saveBtn.text = "保存"
} }
showBrowserMode() // 保存后切回浏览器视图并加载新地址
showBrowser()
} }
val cancelBtn = JButton("取消") val cancelBtn = JButton("取消")
cancelBtn.addActionListener { cancelBtn.addActionListener { showBrowser() }
showBrowserMode()
}
val btnPanel = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)) val btnPanel = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0))
btnPanel.add(saveBtn) btnPanel.add(saveBtn)
@@ -206,45 +275,55 @@ class HarmnessBrowserPanel(
return panel 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() { private fun navigate(url: String) {
browser.jbCefClient.addLifeSpanHandler(object : CefLifeSpanHandlerAdapter() { 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( override fun onBeforePopup(
browser: CefBrowser, cefBrowser: org.cef.browser.CefBrowser,
frame: org.cef.browser.CefFrame, frame: org.cef.browser.CefFrame,
targetUrl: String, targetUrl: String,
targetFrameName: String targetFrameName: String
): Boolean { ): Boolean {
// 拒绝在 IDE 内打开新窗口;改由外部系统浏览器打开
if (targetUrl.isNotBlank()) { if (targetUrl.isNotBlank()) {
BrowserUtil.browse(targetUrl) BrowserUtil.browse(targetUrl)
} }
return true // 取消弹窗 return true // 取消弹窗
} }
}, browser.cefBrowser) }, b.cefBrowser)
browser.jbCefClient.addDisplayHandler(object : CefDisplayHandlerAdapter() {
override fun onTitleChange(browser: CefBrowser, title: String?) {
// 可用来同步工具窗口标题,暂时留空
}
}, browser.cefBrowser)
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// JS ↔ Java 桥 // JS ↔ Java 桥
// ------------------------------------------------------------------ // ------------------------------------------------------------------
private fun setupBridge() { private fun setupBridge(b: JBCefBrowser) {
query = JBCefJSQuery.create(browser) val q = JBCefJSQuery.create(b)
query.addHandler { request -> query = q
q.addHandler { request ->
val r = request ?: "" val r = request ?: ""
LOG.debug("JS bridge request: $r") logger.debug("JS bridge request: $r")
when { when {
r == "ping" -> JBCefJSQuery.Response("pong") r == "ping" -> JBCefJSQuery.Response("pong")
r == "getUrl" -> JBCefJSQuery.Response(browser.cefBrowser.url) r == "getUrl" -> JBCefJSQuery.Response(b.cefBrowser.url)
r.startsWith("openExternal:") -> { r.startsWith("openExternal:") -> {
val url = r.removePrefix("openExternal:") val url = r.removePrefix("openExternal:")
BrowserUtil.browse(url) BrowserUtil.browse(url)
@@ -254,52 +333,65 @@ class HarmnessBrowserPanel(
} }
} }
// 注入桥接函数:页面加载完成后注入到 window.harmnessBridge // 注入桥接函数:主 frame 加载完成后
browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { b.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() {
override fun onLoadEnd(browser: CefBrowser, frame: org.cef.browser.CefFrame, httpStatusCode: Int) { override fun onLoadEnd(cefBrowser: org.cef.browser.CefBrowser, frame: org.cef.browser.CefFrame, httpStatusCode: Int) {
if (frame.isMain && loadEnded.compareAndSet(false, true)) { if (frame.isMain) {
val jsCode = """ val jsCode = """
window.$JS_BRIDGE_NAME = function(payload) { window.$JS_BRIDGE_NAME = function(payload) {
var callbacks = payload && payload.callbacks; var callbacks = payload && payload.callbacks;
${query.inject( ${q.inject(
"payload.query", "payload.query",
"function(response) { if (callbacks && callbacks.onSuccess) callbacks.onSuccess(response); }", "function(response) { if (callbacks && callbacks.onSuccess) callbacks.onSuccess(response); }",
"function(error_code, error_message) { if (callbacks && callbacks.onFailure) callbacks.onFailure(error_code, error_message); }" "function(error_code, error_message) { if (callbacks && callbacks.onFailure) callbacks.onFailure(error_code, error_message); }"
)}; )};
}; };
// 通知页面桥接已就绪
if (window.__HARMNESS_READY__) window.__HARMNESS_READY__(); if (window.__HARMNESS_READY__) window.__HARMNESS_READY__();
""".trimIndent() """.trimIndent()
browser.executeJavaScript(jsCode, browser.url, 0) b.cefBrowser.executeJavaScript(jsCode, cefBrowser.url, 0)
} }
} }
}, browser.cefBrowser) }, b.cefBrowser)
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 对外方法(供 ToolWindow 调用) // 对外方法
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/** 加载当前设置中的起始地址(初次打开 / 设置变更后调用) */
fun loadCurrentUrl() { fun loadCurrentUrl() {
if (!::browser.isInitialized) return val b = browser ?: return
val url = settings.startUrl val url = settings.startUrl
addressField.text = url addressField.text = url
browser.loadURL(url) b.loadURL(url)
loadEnded.set(false) }
/** 工具窗口被选中时由工厂回调:若浏览器存在则刷新,否则尝试初始化 */
fun onToolWindowSelected() {
if (browser == null) {
initBrowserSafely()
} else {
loadCurrentUrl()
}
} }
override fun dispose() { override fun dispose() {
loadEnded.set(false) query?.dispose()
if (::query.isInitialized) { try {
query.dispose() browser?.let { Disposer.dispose(it) }
} } catch (t: Throwable) {
if (::browser.isInitialized) { logger.warn("dispose browser failed", t)
Disposer.dispose(browser)
} }
browser = null
query = null
} }
companion object { 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" 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.project.Project
import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory 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.ContentFactory
import com.intellij.ui.content.ContentManagerEvent
import com.intellij.ui.content.ContentManagerListener
import java.awt.BorderLayout
/** /**
* 本插件的工具窗口工厂。 * 工具窗口工厂:创建侧边栏 "Harmness Browser"
* *
* 工具窗口是 IntelliJ 侧边栏的区域(如 Project、Terminal、Version Control * 采用防御式创建:面板构造或 JCEF 初始化失败时不会让整个工具窗口空白
* 由 IntelliJ 自动管理生命周期。我们在此创建一个自定义工具窗口 "Harmness Browser" * 而是显示具体错误信息,便于排查。
* 其内容就是 [HarmnessBrowserPanel]。
*/ */
class HarmnessToolWindowFactory : ToolWindowFactory, DumbAware { class HarmnessToolWindowFactory : ToolWindowFactory, DumbAware {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
LOG.info("Creating Harmness Browser tool window for project: ${project.name}") 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) val content = ContentFactory.getInstance().createContent(panel, "Browser", false)
toolWindow.contentManager.addContent(content) toolWindow.contentManager.addContent(content)
// 当工具窗口被选中时刷新 toolWindow.contentManager.addContentManagerListener(object : ContentManagerListener {
val contentMgr = toolWindow.contentManager override fun selectionChanged(event: ContentManagerEvent) {
contentMgr.addContentManagerListener(object : com.intellij.ui.content.ContentManagerListener { if (event.content != toolWindow.contentManager.selectedContent) return
override fun selectionChanged(event: com.intellij.ui.content.ContentManagerEvent) { runCatching { (panel as? HarmnessBrowserPanel)?.onToolWindowSelected() }
val isActive = event.content == contentMgr.selectedContent .onFailure { LOG.warn("selectionChanged handler failed", it) }
WindowFocusService.getInstance().setActive(isActive)
if (isActive) {
panel.loadCurrentUrl()
}
} }
}) })
} }
@@ -2,13 +2,15 @@ package com.harmness.toolwindow
import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.Service import com.intellij.openapi.components.Service
import com.intellij.openapi.components.ServiceManager
import com.intellij.openapi.components.State import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.Logger
/** /**
* 插件设置:仅保存「浏览器起始地址」。 * 插件设置:仅保存「浏览器起始地址」。
*
* 通过 @Service 注解注册(无需在 plugin.xml 中重复声明)。
*/ */
@State(name = "HarmnessToolwindowSettings", storages = [Storage("harmness-toolwindow.xml")]) @State(name = "HarmnessToolwindowSettings", storages = [Storage("harmness-toolwindow.xml")])
@Service(Service.Level.APP) @Service(Service.Level.APP)
@@ -38,7 +40,6 @@ class SettingsState : PersistentStateComponent<SettingsState.State> {
// 与 DSH Web GUI 默认端口一致 // 与 DSH Web GUI 默认端口一致
const val DEFAULT_URL = "http://127.0.0.1:8899" const val DEFAULT_URL = "http://127.0.0.1:8899"
fun getInstance(): SettingsState = fun getInstance(): SettingsState = service()
ServiceManager.getService(SettingsState::class.java)
} }
} }
@@ -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" factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"
anchor="right" anchor="right"
/> />
<applicationService
serviceImplementation="com.harmness.toolwindow.SettingsState"/>
<applicationService
serviceImplementation="com.harmness.toolwindow.WindowFocusService"/>
</extensions> </extensions>
</idea-plugin> </idea-plugin>